React Hooks: context, state and effects

A case study

More than one website I’ve worked on has suffered from a problem that is common for sites that allow user logins. When a user is logged in, the server produces customized output for the user. It does this most obviously by displaying the user’s profile picture instead of a “Sign in” link. For users who are not logged in, the server does no customization and serves up the same page every time. This means that we can use a CDN to serve static pages to logged-out users faster than we can serve customized pages to logged-in users. We’d like more users to log in, and rewarding them with a slower site seems wrong.

My current team is working to fix this on our site. Instead of making user-specific customizations (such as displaying a profile picture) on the server, we plan do those customizations on the client side, with JavaScript. This will allow us to serve the same static pages, via CDN, to all users. Once those pages load, client-side JavaScript can query the server to find out whether the user is logged in and (if so) what the URL to their profile photo is. When the response to this query arrives, the client-side JavaScript can either insert the user’s photo into the page or insert a “Sign in” link if the user is not logged in.

There are a number of different ways to do this kind of client-side customization. We’ve chosen to use the React framework because of its ubiquity (we’re betting that this will make it easier to find contractor to help us with our work) and its robust and relatively mature ecosystem of tools.

I began the profile picture customization work shortly after React 16.8 was released. 16.8 was an important release for the React team because it defines a powerful new way to define components, using a mechanism known as hooks. The use of React’s new hooks feature is entirely optional, and there is no need for React developers to convert their existing components to use them. But because I was beginning a new project, I chose to use hooks and found that I really liked this new model of React programming. My work to move the profile picture customization from the server to the client allowed me to use hooks to manage context, state and effects, and I think that makes it an interesting case study to share with readers who use React.

Introduction to Hooks

Simple React components can be written as functions that take a properties object as its input and return a string or a JSX element to be rendered. For example:

function Link({href, children}) {
return <a href={href}>{children}</a>;
}

Before the introduction of hooks in React 16.8, however, functional components were quite limited: they could not define local state variables, and they could not perform side effects when first rendered (or “mounted”) or when re-rendered. In order to create components that could do those things, we had to define them as classes that inherit from React.Component. The render() method of such a class would take a properties object as its input and would return the content to be rendered. In addition, however, this render() method could use the properties of the this.state object to customize its output. React components defined as classes inherit a setState() method from the superclass. If the render() method outputs HTML elements with event handlers, one of those handlers might call setState() to alter the this.state object and cause the component to be re-rendered in its new state. Finally, class-based components can define “lifecycle methods” such as componentDidMount() (called by React after the first render) and componentDidUpdate() (called by React after subsequent renders). A componentDidMount() method could be used to register global event handlers or to request data with a fetch() call, for example.

In React 16.8, hooks allow us to use state and define lifecycle effects in functional React components. Hooks are just functions–imported from the React module–that you call within your component. By convention hook functions have names that begin with use: in this blog post we’ll describe the useContext(), useState(), and useEffect() hooks. There are also important conventions (the React documentations calls them “The Rules of Hooks”) that govern how you can call hooks: you can only use them within functional React components, not class-based components. And they must be called at the top-level of your component functions: you cannot use them within conditionals, loops, or nested functions.

The idea that you can call the useState() function to somehow introduce local state variables into your functional component is deeply counterintuitive, and React has to do some magic behind the scenes to make it work. I typically frown on APIs that rely on behind-the-scenes magic, but I do have to admit that once I got used to them, I’ve found hooks to be quite satisfying to use in practice. So let’s peek behind the curtain to develop a bit of intuition about how hooks work: the key thing to realize is that when you define a functional React component, your function will only be called in a very specific and controlled way, when React is building or updating its tree of components. So even though a functional component does not have an associated instance the way a class-based component does, each invocation of your functional component is, in fact, associated with a specific node in the tree that React is building. Hooks give us a way to associate things (like context, state and effects) with the nodes in that internal React data structure.

That’s a hand-wavy explanation, but hopefully it is a plausible enough explanation of why hooks work. With class-based components, each instance of the component is its own private data structure within the larger tree that React is building. With functional components, hooks let us insert data–in very specific and controlled ways–directly into that tree, and component instances are no longer needed. If you find this explanation convincing enough, then there is no need to think more deeply about how hooks work, and I recommend that you simply embrace the magic of these special use-prefixed functions.

Hooks are best explained by example, so we’ll return now to my desire to display user profile pictures on the client side instead of the server side. The sections that follow introduce the useContext(), useState() and useEffect() hooks.

useContext()

React’s context mechanism is a way to make important data available to any component in the rendered tree without having to pass that data from parent to child down through the entire tree as a property. A canonical use-case for contexts is to make UX theme data available to all components in the tree. For my profile picture work I chose to make user data (including the profile picture URL) available through the React context mechanism because I expect that we’ll be adding more user data on which to base customizations. If, in the future, we allow users to specify a preferred theme, for example, then many components may need to be aware of that user preference.

Working with contexts in React involves using a context “Provider” component high up in the component tree and passing the context data to that component as a prop. When a class-based React component needs to access the context data, it renders a context “Consumer” component with a function as its only child. React invokes that function with the context data as its argument, and then that function behaves like a render method returning the elements to be displayed. This is an awkward bit of indirection that has always made the React context mechanism seem complicated. Fortunately, hooks do away with the need to render a context consumer, and make it very easy to create components that use context data. As an example, here is a simplified version of my Login component that uses the context mechanism to get data about the current user:

import { useContext } from 'react';   // Import the useContext hook
// Import the module that defines the user context
import UserProvider from './user-provider.jsx';
export default function Login() {
// Get data about the current user by calling useContext()
const userData = useContext(UserProvider.context);
  if (!userData) {
// If we don't have the user data yet, don't render anything
return null;
} else if (userData.isAuthenticated) {
// If we have user data and the user is logged in, render
// the user's profile pic
return (
<img width=48 height=48
src={userData.gravatarUrl.small}
alt={userData.username}
/>
);
} else {
// Otherwise, show a login prompt
return (
<a href={`/users/github/login/?next=${
window.location.pathname
}`}>
Sign in
</a>
);
}
}

Line 1 of this code imports the useContext() hook from the React module. Line 4 imports the UserProvider module that defines the context object we want to use. (We’ll see the code in this module later in this blog post). And line 8 calls the useContext() hook passing the UserProvider.context object to specify which context we want to use. The return value of this useContext() call is whatever data the context provider provides.

The rest of the code listing shows how this (simplified) Login component uses the context data. There are three possible cases. In the first case, there is no user data available at all, probably because it has not been fetched yet. In that case the Login() function returns null, rendering nothing. Otherwise, if the userData object is non-null and shows that the user is logged in, then the component renders the user’s profile picture. And finally, if we have userData, but it shows that the user is not logged in, then the component renders a “Sign in” link.

You can see that the useContext() hook makes it really easy for my Login component to access and use information about the current user. But where does that context data come from? Here’s an excerpt from the top-level index.jsx file that kicks off the React rendering process:

ReactDOM.render(
<DocumentProvider>
<UserProvider>
<Page />
</UserProvider>
</DocumentProvider>,
document.getElementById('react-container')
);

In this React-based version of site that I’m working on, a page is rendered by a Page component, inside a UserProvider component which is itself inside a DocumentProvider component. The UserProvider component is responsible for setting up the user context object and providing the appropriate data for that. (DocumentProvider does something similar for the content of the page, and is also responsible for client-side navigation among pages on the site, but that is a topic for another blog post.)

So, when an page is rendered, the UserProvider component sets up a context object for sharing data about the user, and provides that data. Then it renders its child, the Page component. The Page component renders a Header component (which we haven’t discussed here) which in turn renders the Login component. The Login component uses the useContext() hook to get access to the context data that the UserProvider context defined.

Here’s a very simplified version of how the UserProvider component is implemented:

import { createContext } from 'react';
const context = createContext(null);
export default function UserProvider({ children }) {
return (
<context.Provider value={null}>
{children}
</context.Provider>
);
}
UserProvider.context = context;

This code imports React’s createContext() function and uses that function to create a context object for sharing user context with other components. It then defines an exported UserProvider component, and tacks the context object onto the UserProvider component so that it is exported, too. The way the React context API works is that every context object has a Provider property whose value is a React component. To set the value of a context, you render this <context.Provider> component, setting its value prop to the context data to be shared with consumers. The UserProvider component defined above tries to hide some of this complexity: it renders the context.Provider component, hardcoding a value of null, and then renders its own children inside of that provider.

I’ve shown this code here to demonstrate how React context providers work. Note, however, that this version of UserProvider is completely useless, because it always provides the value null. In order to actually provide meaningful data, we need to introduce the useState() and useEffect() hooks.

useState()

React components use state when they want to change their appearance based not on properties passed in to them, but instead based on something that has happened to them. If you were implementing a dropdown menu with React, for example, you might use a state variable to keep track of whether the menu is opened or closed. The component’s rendering code would be written to render the menu in its open or closed state based on the value of the state variable. And the rendered menu would have mouse event handlers that would set the state variable depending on where and when the user clicked the mouse. One of the key things to understand about state in React components is that when the state changes, React re-renders the component. So in a dropdown menu component, a user’s mouse click might cause the component’s open state variable to change from false to true. This state change causes the menu to be re-rendered. The render code looks at the value of that open variable and renders the open form of the menu instead of the closed form of the menu.

Most often when a React component uses state, state changes are caused by mouse or keyboard events. But state can also changes based on timer or network events. It is network events that we’re interested in for the UserProvider component. When the page first loads, the UserProvider will be in its initial state: it won’t have any data about the user, and the context value will just be null. But we’ll ask the server to tell us about the current user (which it can do based on the user’s session cookie). And when we get a response to that query, the UserProvider will be in a new state: the state where it knows about the user.

Here’s a version of the UserProvider component that uses the useState() hook to define and manage it state:

import { createContext, useState } from 'react';
const context = React.createContext(null);
export default function UserProvider({ children }) {
const [userData, setUserData] = useState(null);
return (
<context.Provider value={userData}>
{children}
</context.Provider>
);
}
UserProvider.context = context;

This version of UserProvider imports the useState() hook, and then calls it to define a state variable. The argument to useState() (in this case null) is the initial value of the state. The return value of useState() is an array of two elements. The first is the current value of the state variable, and the second is a function that you can use to set a new value for the variable. It is conventional to use destructuring assignment into a matched pair of names (like userData and setUserData) as we have done here.

Our call to useState() returns the current value of the state variable and stores it in the userData constant. On the next line, we use userData as the value of the context provider component. This means that any descendant components (like our Login component) that call useContext(UserProvider.context) will have access to the value of that userData state variable.

When this UserProvider component is first rendered, the useState() is called for the first time, and its null argument is used as the initial state value, and as the first element of the returned array. If setUserData() were to be called, the argument to that function would become the new value of the state variable. The state change would cause the component to be re-rendered. In that new render, useState() is called a second time. But this time, the null argument is ignored and the new current value is returned as the first element of the array. (As noted in the introduction to hooks above, this is the magical and non-intuitive part of hooks. If you’re feeling confused right now you might want to go re-read that section.)

In our case, there isn’t anything that ever calls setUserData(), so the state never changes, and this UserProvider always provides the same useless value null. We need to ask the server for information about the user, and to do that we need the useEffect() hook.

useEffect()

The useEffect() hook takes a function as its first argument and, by default, runs that function after every render. Used with one argument like this, it is similar to a class-based component that defines componentDidMount() and componentDidUpdate() methods. If the effect you want to achieve is something like registering a global event hander, then you may need cleanup code to remove the event handler when it is no longer needed. If your effect function returns a function, React will remember that function and invoke it before the next invocation of the effect function, and also when the component is unmounted. I won’t be using that feature in this blog post, but it is worth remembering that effects registered with useEffect() have a cleanup option.

For our UserProvider component, the effect we want is to fetch user data from the server. But we only need to do this on the first render. We don’t expect the data to change, so we don’t want to re-fetch it on subsequent renders. useEffect() takes an array as an optional second argument. If you pass an array, then the values in it are used as a kind of cache key, and your effect function is only called on the first render, or when the values in the array change.

Suppose you are writing a component that has three properties and two state variables. The component will be rerendered if any of those five values changes. But suppose that it uses the useEffect() hook to register an effect function that only depends on two of those five values and does not need to be re-run when any of the other three values change. In that case, you can simply pass an array of those two relevant values as the second argument to useEffect(). Your effect function will only be invoked on the first render, and then on any subsequent render when the array of values is different than the array passed previously.

Notice that this means that if you pass an unchanging constant array as the second argument, then the effect function is only invoked once, and we get something similar to componentDidMount() for class-based components. When this is what you want, it is normal to pass the empty array as the second argument, and that is what you’ll see in the updated version of the UserProvider component below:

import { createContext, useState, useEffect } from 'react';
const context = createContext(null);
export default function UserProvider({ children }) {
// The useState() hook defines a state variable.
const [userData, setUserData] = useState(null);
  // The useEffect() hook registers a function to run after render.
useEffect(() => {
fetch('/api/v1/whoami') // Ask the server for user data.
.then(response => response.json()) // Get the response as JSON
.then(data => { // When data arrives...
setUserData({ // set our state variable.
username: data.username,
isAuthenticated: data.is_authenticated,
timezone: data.timezone,
gravatarUrl: data.gravatar_url
});
});
}, []); // This empty array means the effect will only run once.
  // On the first render userData will have the default value null.
// But after that render, the effect function will run and will
// start a fetch of the real user data. When the data arrives, it
// will be passed to setUserData(), which changes state and
// triggers a new render. On this second render, we'll have real
// user data to provide to any consumers. (And the effect will not
// run again.)
return (
<context.Provider value={userData}>
{children}
</context.Provider>
);
}
UserProvider.context = context;

In this final version of UserProvider, the useEffect() and useState() hooks are intertwined: the useEffect() hook triggers a network request for user data after the first render, and when that data arrives, the effect function passes it to the setUserData() function that was returned by the useState() hook. This change to the state variable causes the component to be re-rendered, which updates the context provider with the newly-received user data. And this means that functions like the Login component elsewhere in the tree can access that data with the useContext() hook.

Further Reading

The Login and UserProvider components I’ve described here were fun to work on, and it was particularly satisfying to see how useContext(), useState(), and useEffect() all came together to make them work. If you are a React developer, I hope I’ve passed on my enthusiasm for the new hooks API. If so, here are some ways you can learn more:

  • If you’d like to take a closer look at the (non-simplified versions of the) Login and UserProvider and related components, you can find them on GitHub. (That link is valid at the time of this writing, though UserProvider is actually called CurrentUser. Our React-based code is experimental and changing fast, so it is likely that the code will move and this link will quickly go stale.)
  • A key feature of the UserProvider component described in this post is that it uses client side JavaScript to dynamically request data from the web server. If you are not already comfortable with the fetch() function used to make the network request, you can find comprehensive documentation on MDN.
  • Finally, if you’d like to read more about hooks in React, the documentation from the React project is quite good.