Creating reusable abstractions with Amplify and React hooks

Aggelos Arvanitakis
ITNEXT
Published in
6 min readJun 2, 2019

--

Amplify is perhaps one of the best libraries out there to create your application prototype. As things scale though, you find yourself in need of its modules in multiple places and as a result you directly import them in most of your components. At the same time, there is an interesting shift in the way React developers structure their code. With the introduction of hooks, core concepts like splitting your components into presentational & container are losing ground, while a more holistic approach begins to take over, where the container & presentational parts are integrated into one single entity.

This article will capitalise on this new trend and the concept of custom hooks, in order to draw a line between React & external services with regards to the ways they communicate with one another. In other words, do your React components really need to know that you are using Amplify?

Setting the scene

Most tutorials I’ve seen about Amplify and React, normally import modules like Auth & API directly within the React components. This is similar to writing your data-fetching logic directly within your components, by importing the HTTP library that you are using (i.e. axios) and performing your request right there in a lifecycle method (or useEffect() hook).

Although there is absolutely nothing wrong with that, one could argue that this may create some minor issues in the future. You see, as the app grows, so do the business requirements, the complexity of the code and — most likely — the people that contribute to it. Abstracting away the data fetching layer can yield lots of benefits, such as reducing boilerplate, increasing readability, grouping API-related logic in a single place and helping other developers (and the future you) through consistency in the way your app is structured.

The exact same principles apply to Amplify’s modules. Let’s take the Auth module for example. A lot of components will need access to the currently authenticated user, which in turn translates to calling Auth.currentAuthenticatedUser within these components. What if we could abstract away the way the Auth module is implemented and instead expose a custom interface that’s gonna hide the fact that we are using Amplify under the hood? I’m taking about a simple implementation of the Facade pattern. While there are many ways of achieving that, I believe a nice solution would be through React hooks, as one of their strongest selling points is code reduction & readability. With this in mind, let’s dig straight in.

Masking Amplify’s Auth module with hooks

To give an example of what was described above, I’ll attempt to mask the extended public interface of Amplify’s Auth module, by creating a hook that will only expose a subset of items for our React components to interact with. As mentioned before, the goal here is to add a layer that will only expose what our application needs, without letting React know how is it implemented behind the scenes.

The first thing we need is to store the user instance somewhere. For this reason we will be creating a context through:

import React from 'react';// originally the user is going to be `null`
const UserContext = React.createContext(null);

After that, we need a way of providing this value to the App, which is normally handled through UserContext.Provider component. What we are missing though is checking whether the user is already authenticated as soon as the app gets loaded. To fix that, instead of adding an api call to an existing top-level React component, we are going to create a custom wrapper for UserContext.Provider that will act as its “controller”. This component will be mounted at the top of the tree and perform all the needed side-effects:

import React from 'react';/* The UserContext was previously defined */const UserProvider = (props) => {  
const [user, setUser] = React.useState(null);
// fetch the info of the user that may be already logged in
React.useEffect(() => {
Auth.currentAuthenticatedUser()
.then(user => setUser(user))
.catch(() => setUser(null));
}, []);
// make sure other components can read this value
return (
<UserContext.Provider value={user}>
{props.children}
</UserContext.Provider>
)
}

In order to read this value, other components will need access to it. Let’s make that happen through a custom hook:

import React from 'react';/* The UserContext & UserProvider were previously defined */const useUser = () => {  
const context = React.useContext(UserContext);
if(context === undefined) {
throw new Error('`useUser` must be within a `UserProvider`');
}
return context;
};

The last bit that we are missing, is authentication functions. Our components have access to the user but can’t sign-in or sign-out the user, since they don’t actually know how to. Let’s fix that by extending our previously defined UserProvider so that it doesn’t only return the user instance, but also a set of additional functions related to it:

import React from 'react';const UserProvider = (props) => {  

...
...
const login = (usernameOrEmail, password) =>
Auth.signIn(usernameOrEmail, password)
.then(cognitoUser => setUser(cognitoUser));
const logout = () =>
Auth.signOut()
.then(() => setUser(null));
return (
<UserContext.Provider value={{ user, login, logout }}>
{props.children}
</UserContext.Provider>
)
}

Now our React components will have access to login & logout functions. If we group together all of the above and add some additional optimisations, the final code will look something like this (make sure to read the comments as well):

Implementation of the `useUser` hook,

Before analysing what we gained out of this, I think it would be nice to also showcase how the modules would be used. Take a look at some examples below:

Examples of how `useUser` hook could be used

What we achieved here is to successfully decouple Amplify from React, by mapping Amplify’s extended authentication api to custom functions that are simple and not tied to the way Amplify implements them internally. Their public interface will stay the same even if Amplify’s api changes, which means that it ever releases a breaking change, you will only need to update code in a single place. Moreover, if down the road you decide that Cognito is not for you, you can safely swap it with something like Auth0 without too much hassle, since (again) you will just be editing code in one file. That also means that other developers won’t need to know or understand how authentication works under the hood, which, as a result, makes their on-boarding faster. Writing tests also becomes easier, since this variation of dependency injection that we are performing (through our custom auth interface), makes it easy to write quick & resilient mocks that are not affected by changes in your external packages. That means that you won’t have to mock Amplify itself, but rather your exposed custom authentication interface.

On the other hand, using hooks enables you to quickly write & replace components, reduces the amount of component code, increases maintainability & readability and allows reusability between projects. The latter is perhaps one of the most important perks, since you can now have a re-usable authentication interface that can be shared across all of your projects & teams. Our custom useUser hook also allows you to add additional logic to the exposed functions before serving them to its consumers, so don’t view this hook as a simple wrapper around a useContext call, but rather as a selector to a state held by a Provider.

Although I tried to keep it simple for the purposes of this post, depending on your needs, you can also expand this solution to include additional methods such as a signUp, updateUser, deactivateUser, etc. Just make sure to keep it small & simple. If it becomes too cumbersome to maintain, you can always split the logic into multiple hooks in order to make it more modular. For example, you can have a useUser hook for user related stuff and a useAuth for anything to do with authentication (you would also need to create two different Providers in this case).

Closing notes

The purpose of this post is to see how you can create abstractions that can be re-used not only across different projects, but also across different modules. You can easily expand upon this solution and use it for modules like the Storage or the Analytics one. Abstractions like these increase the modularity & readability of your code, while making maintenance, testing and code interchangeability a breeze, at almost no extra overhead. It has worked great for me up until now and I’d love to hear your thoughts on it.

Cheers :)

P.S. 👋 Hi, I’m Aggelos! If you liked this, consider following me on twitter and sharing the story with your dev friends 😀

--

--