The main purpose of this article is to get an understanding of useContext
and useReducer
and how they can work together to make React applications and their state management clean and efficient.
The new Hooks API allows for some amazing features to be accessed across React applications which can potentially eliminate the need for state management libraries, like Redux or Flux. Better yet, we can accomplish this with functional components. No class components necessary, just JavaScript.
To start, there will be an overview of the "pre-hooks" Context API, best practices, and implementation within a React application. Once the base is layed and an example portrayed, said example will be refactored to implement useContext
. Transitioning from useContext
, a focus on the concepts surrounding a reducer function will lead to the implementation of useReducer
and how it's used to manage complex state. After establishing a solid understanding of the two hooks, they will be combined to create a React-only state management tool.
This is part two of a two-part series:
- Primer on React Hooks
- (this article) React Hooks - A deeper dive featuring useContext and useReducer
Contents
Audience
This is an intermediate-level tutorial for React developers that have a basic understanding of:
- React dataflow
- The
useState
anduseEffect
hooks - Global state management tools and patterns (like Redux and Flux)
- The React Context API
If you're brand new to React hooks, check out the Primer on React Hooks post.
Learning Objectives
By the end of this tutorial, you will be able to:
- Explain what context is
- Identify when context should be implemented
- Implement context via the
useContext
hook - Identify when
useReducer
should be implemented - Implement
useReducer
for component state management - Utilize
useReducer
anduseContext
as an application state management tool - Simplify React by implementing hooks
Project Overview
The project demonstrated will be a 'race series' management tool and by the end of this article it will implement useContext
and useReducer
for the application's state management. The current state of the project at the beginning of this article does not use any state management library, nor does it implement the Context or Hooks APIs. It simply passes logic via props and utilizes React's Render Props. The article will not break down the project in its entirety, but rather focus on refactoring pieces necessary to implement useContext
and useReducer
for its state management.
For the the full project visit it's repo here.
What is a race series management tool?
Simply, a race series has multiple races and multiple participants (users). Not all users are in every race. The application will act as a administration tool by allowing admin access to a list of races and a list of users within the series. When an admin is logged in and selects to view the list of races, they will be able to see all the races within the series. Selecting a single race from that list will display the registered users for that particular race. Contrary, when selecting to view the series, the admin will have the ability to select from the users and view which races a particular user is participating in.
A Review: The Basic Hooks
Start here if you're not familiar with the Hooks API. Hooks allow you to access state and lifecycle methods, plus many other great tricks to use in functional components. No need to write class components. Again, no need to write class components! If you're looking for a solid foundation to build upon, start with useState
and useEffect
.
-
useState: Allows access and control of state within functional components. Prior, only class-based components had access to state.
-
useEffect: Allows abstracted access to React's lifecycle methods within functional components. You won't call the individual lifecycle methods by name, but you gain similar control with more functionality and cleaner code.
Example:
import React, { useState, useEffect } from 'react';
export const SomeComponent = () => {
const [ DNAMatch, setDNAMatch ] = useState(false);
const [ name, setName ] = useState(null);
useEffect(() => {
if (name) {
setDNAMatch(true);
setName(name);
localStorage.setItem('dad', name);
}
}, [ DNAMatch ]);
return (
// ...
);
};
Again, for more details on basic hooks read the primer: Primer on React Hooks.
useContext
Before diving into the context hook, let's look at an overview of the Context API and how it was implemented before the Hooks API. This will lend a greater appreciation of the useContext
hook and a more insight into when context should be used.
Overview of the Context API
A well-known pitfall in React is access to a global state object. When data needs to get down deep in a nested component tree -- themes, UI styles, or user authorizations -- it gets quite cumbersome smuggling data via props through several components that may or may not have any need for that data; components turned data mules. The go-to solution has been state management libraries like Redux or Flux. These libraries are incredibly powerful but can be a bit of a challenge setting up, keeping organized, and knowing when they're necessary to implement.
Here's an example, pre-context API, of props being smuggled deep into lives of child components.
// NO CONTEXT YET - just prop smuggling
import React, { Component, useReducer, useContext } from 'react';
const Main = (props) => (
<div className={'main'}>
{/* // Main hires a Component Mule (ListContainer) to smuggle data */}
<List
isAuthenticated={props.isAuthenticated}
toggleAuth={props.toggleAuth}
/>
</div>
);
const List = ({ isAuthenticated, toggleAuth, shake }) => (
isAuthenticated
? (
<div className={'title'} >
"secure" list, check.
</div >)
: (
<div className={'list-login-container'}>
{/* // And List hires a Component Mule (AdminForm) to smuggle data */}
<AdminForm shake={shake} toggleAuth={toggleAuth} />
</div>)
);
class AdminForm extends Component {
constructor(props) {
super(props);
this.state = {};
this.handleSubmit = this.handleSubmit.bind(this);
}
handleSubmit(event, toggleAuth) {
event.preventDefault();
return toggleAuth(true);
}
render() {
return (
<div className={'form-container'}>
<form
onSubmit={event => this.handleSubmit(event, this.props.toggleAuth)}
className={'center-content login-form'}
>
// ... form logic
</form>
</div>
);
}
}
class App extends Component {
constructor(props) {
super(props);
this.state = {
isAuthenticated: false,
};
this.toggleAuth = this.toggleAuth.bind(this);
}
toggleAuth(isAuthenticated) {
return isAuthenticated
? this.setState({ isAuthenticated: true })
: alert('Bad Credentials!');
}
render() {
return (
<div className={'App'}>
<Main
isAuthenticated={this.state.isAuthenticated}
toggleAuth={this.toggleAuth}
>
</Main>
</div>
);
}
}
export default App;
Implementing the Context API
The Context API was created to solve this global data crisis and stop the smuggling of data, abusing innocent child component's props -- a national emergency if you will. Great, so let's see it.
Here's the same example refactored using the Context API.
// CONTEXT API
import React, { Component, createContext } from 'react';
// We used 'null' because the data we need
// resides in App's state.
const AuthContext = createContext(null);
// You can also destructure above
// const { Provider, Consumer } = createContext(null)
const Main = (props) => (
<div className={'main'}>
<List />
</div>
);
const List = (props) => (
<AuthContext.Consumer>
auth => {
auth
? (
<div className={'list'}>
// ... map over some sensitive data for a beautiful secure list
</div>
)
: (
<div className={'form-container'}>
// And List hires a Component Mule to smuggle data
<AdminForm />
</div>
)
}
</AuthContext.Consumer>);
class AdminForm extends Component {
constructor(props) {
super(props);
this.state = {};
this.handleSubmit = this.handleSubmit.bind(this);
}
handleSubmit(event, toggleAuth) {
event.preventDefault();
return toggleAuth(true);
}
render() {
return (
<AdminContext.Consumer>
{state => (
<div>
<form
onSubmit={event => this.handleSubmit(event, state.toggleAuth)}
className={'center-content login-form'}
>
// ... form logic
</form>
</div>
)}
</AdminContext.Consumer>
);
}
}
class App extends Component {
constructor(props) {
super(props);
this.state = {
isAuthenticated: false,
};
this.toggleAuth = this.toggleAuth.bind(this);
}
toggleAuth(isAuthenticated) {
this.setState({ isAuthenticated: true });
}
render() {
return (
<div>
<AuthContext.Provider value={this.state.isAuthenticated}>
<Main />
</AuthContext.Provider>
</div>
);
}
}
export default App;
This method of using the Context API will still be valid with the introduction of hooks.
Considering Context
There are some other things to consider when implementing context. The Context API will re-render all components that are descendants of the provider. If you're not careful, you could be re-rendering your entire app on every click or keystroke. Solutions? You bet!
Be thoughtful when wrapping components in providers. Console logs never killed the cat.
Create a new component that only accepts the children props. Move the provider and its necessary data into that component. This keeps the children props of the provider equal between renders.
// Navigate.js
import React, { useReducer, useContext } from 'react';
const AppContext = React.createContext();
const reducer = (state, action) => {
switch (action.type) {
case 'UPDATE_PATH': return {
...state,
pathname: action.pathname,
};
default: return state;
}
};
export const AppProvider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, {
pathname: window.location.pathname,
navigate: (pathname) => {
window.history.pushState(null, null, pathname);
return dispatch({ type: 'UPDATE_PATH', pathname });
},
});
return (
<AppContext.Provider value={state}>
{children}
</AppContext.Provider>
);
};
export const LinkItem = ({ activeStyle, ...props }) => {
const context = useContext(AppContext);
console.log(context, 'CONTEXT WTF?@');
return (
<div>
<a
{...props}
style={{
...props.style,
...(context.pathname === props.href ? activeStyle : {}),
}}
onClick={(e) => {
e.preventDefault();
context.navigate(props.href);
}}
/>
</div>
);
};
export const Route = ({ children, href }) => {
const context = useContext(AppContext);
return (
<div>
{context.pathname === href ? children : null}
</div>
);
};
If you use the above methods in the pattern below, you will not have unnecessary re-renders (everything inside render props of <AppProvider>
).
// App.js
import React, { useContext } from 'react';
import { AppProvider, LinkItem, Route } from './Navigate.js';
export const AppLayout = ({ children }) => (
<div>
<LinkItem href="/participants/" activeStyle={{ color: 'red' }}>
Participants
</LinkItem>
<LinkItem href="/races/" activeStyle={{ color: 'red' }}>
Races
</LinkItem>
<main>
{children}
</main>
</div>
);
export const App = () => {
return (
<AppProvider>
<AppLayout>
<Route href="/races/">
<h1>Off to the Races</h1>
</Route>
<Route href="/participants/">
<h1>Off with their Heads!</h1>
</Route>
</AppLayout>
</AppProvider>
);
};
Wrap your consuming elements in a Higher Order Component.
const withAuth = (Component) => (props) => {
const context = useContext(AppContext)
return (
<div>
{<Component {...props} {...context} />}
</div>
);
}
class AdminForm extends Component {
// ...
}
export withAuth(AdminForm);
Context will never replace state management libraries like Redux fully. For example, Redux makes for effortless debugging, grants customization with middlewares, holds a single state and follows pure function practices with connect
. Context is very capable; it can hold state and it's amazingly versatile, but it works best as a provider of data in which components consume.
Render Props is great solution for passing data, but it can create an interestingly hierarchal look to your component tree. Think callback hell, but with HTML tags. Consider using context if data needs to be available deep inside one component tree. This can enhance performance as well since the parent components won't re-render when the data changes.
Implementing useContext
The useContext
hook makes the implementation of consuming context data easy, and it can help make the components reuseable. To make clear the difficulties that can arise with the Context API we'll show a component consuming multiple contexts. Pre-hooks, the multi-context consuming components became difficult to reuse and understand. That's one reason why context should be used sparingly.
Here's an example from above, but with an extra context to consume.
const AuthContext = createContext(null);
const ShakeContext = createContext(null);
class AdminForm extends Component {
// ...
render() {
return (
// this multiple context consumption is not a good look.
<ShakeContext.Consumer>
{shake => (
<AdminContext.Consumer>
{state => (
// ... consume!
)}
</AdminContext.Consumer>
)}
</ShakeContext.Consumer>
);
}
}
class App extends Component {
// ...
<ShakeContext.Provider value={() => this.shake()}>
<AuthContext.Provider value={this.state}>
<Main />
</AuthContext.Provider>
</ShakeContext.Provider>
}
export default App;
Now imagine three or more contexts to consume... [ head explodes : end scene]
After a swim across the sea of context, take a breath and revel in the handiness of your new knowledge. It was a long journey and if you look back at the wake of our efforts, they're still visible. But with time, the waters calm, giving into gravity.
Context did the same, and gave into JavaScript. Enter useContext
.
The new hook to consume context does not change the concepts surrounding context, hence the plunge above. This context hook only gives us an extra, much prettier, way to consume context. It's amazingly helpful when applying it to components consuming multiple contexts.
Here we have a refactored version of the above component, consuming multiple contexts with hooks!
const AdminForm = () => {
const shake = useContext(ShakeContext);
const auth = useContext(AuthContext);
// you have access to the data within each context
// the context still needs be in scope of the consuming component
return (
<div className={
shake ? 'shake' : 'form-container'
}>
{
auth.isAuthenticated
? user.name
: auth.errorMessage
}
</div>
);
};
That's it! Whatever your context holds, be it an object, array, or function, you have access to it through useContext
. Amazing. Next, let's dive into reducer, discuss its advantages, and then implement the useReducer
hook.
useReducer
To make decisions easier surrounding implementation, we'll go over relevant concepts, practical use cases, and then implementation. We'll go from a class to functional component using useState
. Following that, we'll implement useReducer
.
Concepts surrounding useReducer
If you are familiar with at least two of the following three concepts, you can skip to the next section.
If you don't believe you are comfortable enough with any of the above, take a deeper look by reviewing the points below:
useReducer and useState: The useState
hook allows you to have access to one state variable inside a functional component with a single method to update it -- i.e., setCount
. useReducer
makes updating state more flexible and implicit. Just as Array.prototype.map
and Array.prototype.reduce
can solve similar problems, Array.prototype.reduce
is much more versatile.
useState
usesuseReducer
under the hood.
The following example will arbitrarily demonstrate the inconvenience of having more than one useState
method.
const App = () => {
const [ isAdminLoading, setIsAdminLoading] = useState(false);
const [ isAdmin, setIsAdmin] = useState(false);
const [ isAdminErr, setIsAdminErr] = useState({ error: false, msg: null });
// there's a lot more overhead with this approach, more variables to deal with
const adminStatus = (loading, success, error) => {
// you must have your individual state on hand
// it would be easier to pass your intent
// and have your pre-concluded outcome initialized, typed with intent
if (error) {
setIsAdminLoading(false); // could be set with intent in reducer
setIsAdmin(false);
setIsAdminErr({
error: true,
msg: error.msg,
});
throw error;
} else if (loading && !error && !success) {
setIsAdminLoading(loading);
} else if (success && !error && !loading) {
setIsAdminLoading(false); // .. these intents are convoluted
setIsAdmin(true);
}
};
};
useReducer and Array.prototype.reduce: useReducer
acts very similar to Array.protoType.reduce
. They both take very similar parameters; a callback and an initial value. In our case, we'll call them reducer
and initialState
. They both take in two arguments and return one value. The major difference is that useReducer
adds more functionality with the return of dispatch.
useReducer and Redux Reducer: Redux is a much more complex tool for application state management. Redux reducers are typically accessed via dispatches, props, connected class components, actions and (possibly) services and maintained in the Redux store. Implementing useReducer
, though, leaves all that complexity behind.
I believe there will be incredible updates to many popular React packages that will make our React development without Redux even more entertaining. Knowledge of these new practices will help lead to an easy transition to the fancy updates coming in the near future.
Opportunities for useReducer
The Hooks API simplifies component logic and gives riddance to the class
keyword. It can be initialized as many times as necessary within your component(s). The difficult part, much like context, is knowing when to use it.
Opportunities
You may want to look at implementing useReducer
when a:
- Component requires a complex state object
- Component's props will be used to calculate the next value of your component state
- Particular
useState
update method depends on anotheruseState
value
Applying useReducer
in these instances will ease the visual and mental complexity such patterns conjure and provide continuous breathing room as your app grows. Put simply: You're minimizing the amount of state dependent logic scattered throughout your component and adding the ability to express your intent rather than the result when updating state.
Advantages
- No need for component and container separations
- The
dispatch
function is easily accessible within our component's scope - The ability to manipulate state on initial render with a third argument,
initialAction
Let's get to the code already!
Implementing useReducer
Let's look at a small piece of the first context example above.
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
isAuthenticated: false,
};
this.toggleAuth = this.toggleAuth.bind(this);
}
toggleAuth(success) {
success
? this.setState({ isAuthenticated: true })
: 'Potential errorists threat. Alert level: Magenta?';
}
render() {
// ...
}
}
Here's the sample above refactored with useState
.
const App = () => {
const [isAuthenticated, setAuth] = useState(false);
const toggleAuth = (success) =>
success
? setAuth(true)
: 'Potential errorists threat. Alert level: Magenta?';
return (
// ...
);
};
It's amazing how much prettier and familiar this looks. Now, as we add more logic to this app, useReducer
will become useful. Let's start the additional logic with useState
.
const App = () => {
const [isAuthenticated, setAuth] = useState(false);
const [shakeForm, setShakeForm] = useState(false);
const [categories, setCategories] = useState(['Participants', 'Races']);
const [categoryView, setCategoryView] = useState(null);
const toggleAuth = () => setAuth(true);
const toggleShakeForm = () =>
setTimeout(() =>
setShakeForm(false), 500);
const handleShakeForm = () =>
setShakeForm(shakeState =>
!shakeFormState
? toggleShakeForm()
: null);
return (
// ...
);
};
That's decently more complex. What would this look like with useReducer
?
// Here's our reducer and initialState
// outside of the App component
const reducer = (state, action) => {
switch (action.type) {
case 'IS_AUTHENTICATED':
return {
...state,
isAuthenticated: true,
};
// ... you can image the other cases
default: return state;
}
};
const initialState = {
isAuthenticated: false,
shake: false,
categories: ['Participants', 'Races'],
};
We will use these variables at the top level of our App component with useReducer
.
const App = () => {
const [state, dispatch] = useReducer(reducer, initialState);
const toggleAuth = (success) =>
success
? dispatch({ type: 'IS_AUTHENTICATED' }) // BAM!
: alert('Potential errorists threat. Alert level: Magenta?');
return (
// ...
);
};
This code expresses our intentions well. This is great and all, but we need to get this logic into context so we can consume it down the tree. Let's look at our auth provider and see how we can provide it logic.
const App = () => {
// ...
<AdminContext.Provider value={{
isAuthenticated: state.isAuthenticated,
toggle: toggleAuth,
}}>
<Main />
</AdminContext.Provider>
// ...
};
There's many ways to do this, but the idea is that we have a piece of state and a function dispatching an action passed to the provider. This is much like earlier except the difference is the location and organization of the logic. Big whoop... right?
What if you passed value={{ state, dispatch }}
to your provider's value prop? What if we extracted our provider into a wrapper function? You could rid your component of logic that couples it tightly to other components. Further, it's much better to pass your intention (action) vs. how you intend to do it (logic).
State Management with Hooks
Here we have our same App
, reducer
, and initialState
as before, except we were able to remove logic that was being passed through context. Instead, we will take care of the logic in the component that will, in turn, execute our intention.
const App = () => {
const [state, dispatch] = useReducer(reducer, initialState);
// ...
<AdminContext.Provider value={{ state, dispatch }}>
<Main />
</AdminContext.Provider>
// ...
};
Here's the necessary logic for our intentions, where it should be.
const AdminForm = () => {
const auth = useContext(AdminContext);
const handleSubmit = (event) => {
event.preventDefault();
authResult()
? dispatch({ type: 'IS_AUTHENTICATED' })
: alert('Potential errorists threat. Alert level: Magenta?');
};
const authResult = () =>
setTimeout(() => true, 500);
return (
<div className={'form-container'}>
<form
onSubmit={event => handleSubmit(event)}
className={'center-content login-form'}
>
// ... form stuff..
</form>
</div >
);
};
But we can do better. Extracting the provider component with a wrapper will give us an efficient way to pass data without unnecessary re-renders.
// AdminContext.js
const reducer = (state, action) => {
switch (action.type) {
case 'IS_AUTHENTICATED':
return {
...state,
isAuthenticated: true,
};
// ... you can image other cases
default: return state;
}
};
const initialState = {
isAuthenticated: false,
// ... imagine so much more!
};
const ComponentContext = React.createContext(initialState);
export const AdminProvider = (props) => {
const [ state, dispatch ] = useReducer(reducer, initialState);
return (
<ComponentContext.Provider value={{ state, dispatch }}>
{props.children}
</ComponentContext.Provider>
);
};
// App.js
// ...
const App = () => {
// ...
<AdminProvider>
<Main />
</AdminProvider>
// ...
};
So it's a little more "complex", but it's well-organized, readable, and accessible. It's also easier to manage when your app grows and more context is needed. You can split your context files up by component or how ever you see fit. This is your time to play around and explore with what works best for you, your team, and your application! Go get'm!
Conclusion
In my opinion, you should definitely consider using hooks for state management in your apps. Just as useState
is great for a simple component's state management, useReducer
is capable of handling a nest of components. Will a 'monolithic state of the app' give way to a 'micro state of the nest'? Will this pattern create micro states as a more manageable alternative like a well-designed fleet of micro services?
At the moment hooks are not a full Redux replacement, but they can make your journey easier as you venture into the uncharted waters of large production apps.
Implementing useReducer
is fairly simple and painless. Refactoring from useReducer
to Redux is probably easier than from scratch. You'll have most of what you need at your finger tips. With that, I recommend using hooks for state management on small to medium sized applications and look forward to seeing new ways to efficiently and effectively implement a robust, manageable state management pattern like Redux with much less overhead. Thank you React, and thank you for reading.
Hooks vs Redux: Thoughts and Opinions
Are you thinking about implementing hooks for state management over a more structured framework like Redux? Take note of the following pros and cons:
Pros
- Less boilerplate
- Faster development (up to a point, for smaller and medium-sized apps)
- Major refactors can become less complex and painful
- More control, but you have to account for performance issues associated with massive re-renders
- Better if you need to sprinkle pieces of state here and there
- Part of the React core API
Cons
- Less developer tool support (like time-travel debugging)
- No standard global state object
- Less resources and conventions
- Not great for developers new to React that benefit from the structure that Redux provides
- Difficult to extract and pass only relevant data for larger applications
To expand on those new to React who are starting work on an existing project, hooks have the potential to be less overwhelming and jargon-ridden. So, for small to medium-sized apps, new developers would probably fair better blind with hooks than Redux. On the flip side, for larger apps, if there's enough complexity abstracted away to a Redux store that's already set up, then there's a convention that's easy to convey to any developer.
There are obvious benefits to Redux, mostly due to vast adoption and maturity. In the realm of conventions, resources (like blog posts and Stack Overflow questions, to name a few), libraries, and better developer tools. Debugging a nasty complex state object can be somewhat straightforward with Redux if you know what you're doing. In other words, just because a developer can see an issue doesn't necessarily mean said developer knows HOW to track it down AND fix it or even have access to the problem logic. Also, with large apps, stores can become overwhelming. It's like picking out Olive Oil at Whole Foods -- it's a lot to take in. Hooks allow us to compartmentalize our data/reducers/logic to specific nests that can be shared everywhere. Performance should be better with Redux, especially for larger apps. That doesn't mean it's a guarantee. It's up to developers to implement Redux correctly, be diligent about benchmarking, and keep an eye on the state structure. With hooks, you will probably run into performance issues before Redux, but from my limited time with hooks, I feel it's easier to track down AND fix such issues with some of the tricks mentioned above. It's also an opportunity to implement custom logic that mimics mapStateToProps
and mapDispatchToProps
, which should increase performance as your app grows. It's up to you to run experiments and decide for yourself what works best for the situation at hand. And remember: It's not an "either/or" situation -- you can use hooks and Redux together for your app's state management.