How To Build a Redux-Like Store With React Context and Hooks

And add authentication along with routing to your app

November 04, 2019

On a fine Saturday morning you wake up with a brilliant idea for your next side project. You have been thinking about it all week and now you're ready to dive in. Anyway you wanted to experiment with all that hot new tech and frameworks you have been missing out in your boring day job.

You have the idea roughly sketched out for a frontend application using all the latest and greatest features of React(Context, hooks etc etc) along with a serverless backend(Maybe using Cloudflare Workers?) You open your favorite editor with a shiny new Create React App running ready to be The Next Big Thing. And bam! few hours in to development you realize you actually haven't done anything but ended up with dozens of tutorial tabs and docs open only to be confused and frustrated with all these new features and jargon.

That's exactly where I was when I decided to write this guide to help myself organize my learning and hopefully share that knowledge with a frustrated dev like me. In this guide I'm going to start with basics of both Context and Hooks and gradually integrate them with each other to create a simple but functional state manager like Redux.

TL;DR Just show me the code

State Management in React

So let's go back a little and define my requirements. I want to setup a React application,

  • Use Context and Hooks for global state management
  • Implement authentication using global state
  • Configure routing with public and private routes

If you have these three in place rest of the app is pretty much usual react business.

Working with global state using Redux is fairly straightforward. You implement a store with some initial value, write reducers that will help you update the store, write actions and action creators used to dispatch updates to store. Then you simply connect any component in your application to the store to be able to use the global state or make updates.

We are going to see how we can achieve something similar using Context and Hooks. Our plan would be,

  • Implement simple state management using Hooks
  • Convert this state to be a global state using React Context
  • Abstract away the Hooks+Context logic into a nice reusable API similar to Redux with a store, reducers and actions
  • Use the created store to implement simple authentication along with Routing

Let’s start with Create React App and experiment a little.

npx create-react-app react-context-example
cd react-context-example
yarn start

We will start with a very simple Todo application which has three components as follows.

alt alt

Let’s add the following components.

import React from "react";
export function NewItem({ add }) {
return (
<div className="Item">
<input type="text" placeholder="New Task"></input>
<button onClick={() => add("New")}>Add</button>
</div>
);
}
export function ItemList({ items = [], remove }) {
return items.map((item, i) => <Item text={item} index={i} key={i} remove={remove} />);
}
export function Item({ text, index, remove }) {
return (
<div className="Item">
{index + 1} {text}
<span onClick={() => remove(index)}>Done</span>
</div>
);
}
view raw Items.js hosted with ❤ by GitHub

components/Items.js

.App {
text-align: center;
}
.App-logo {
height: 40vmin;
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #09d3ac;
}
.Item {
font-size: calc(10px + 2vmin);
display: flex;
flex-direction: row;
padding: 5px;
}
.Item input {
font-size: calc(10px + 2vmin);
padding-left: 5px;
border: none;
}
.Item button {
font-size: calc(10px + 2vmin);
background-color: #09d3ac;
padding: 5px 10px;
border: none;
}
.Item span {
color: #fc4040;
padding-left: 5px;
font-size: 15px;
cursor: pointer;
}
view raw App.css hosted with ❤ by GitHub

App.css to make it look nice :)

import React from "react";
import { ItemList, NewItem } from "./components/Items";
import "./App.css";
const items = ["Setup basic components", "Add some styling"];
function App() {
return (
<div className="App">
<header className="App-header">
<h2>🚀 ToDo App</h2>
<NewItem />
<ItemList items={items} />
</header>
</div>
);
}
export default App;
view raw App.js hosted with ❤ by GitHub

App.js

Next we want to introduce a state to store the list of todos and be able to add and remove todo items.

State Using Hooks

Hooks are a new addition in React 16.8. They let you use state and other React features without writing a class.

Previously we would have converted App component into a class component and introduced state to the class. But with react hooks we can keep it as a functional component and introduce state using the useState hook. A very nice introduction to hooks can be found in hooks documentation.

Let’s update App.js as follows.

import React, { useState } from "react";
import { ItemList, NewItem } from "./components/Items";
import "./App.css";
const initialItems = ["Setup basic components", "Add some styling"];
function App() {
// useState hook returns two values. First is the state itself
// and second is a function that we can use to update the state
const [items, setItems] = useState(initialItems);
function handleAddItem(item) {
setItems([...items, item]);
}
function handleRemoveItem(index) {
const copy = [...items];
copy.splice(index, 1);
setItems(copy);
}
return (
<div className="App">
<header className="App-header">
<h2>🚀 ToDo App</h2>
<NewItem add={handleAddItem} />
<ItemList items={items} remove={handleRemoveItem} />
</header>
</div>
);
}
export default App;
view raw App.js hosted with ❤ by GitHub

Here we have declared an array of items as a state variable using the useState hook. It takes the initial state as a parameter and returns two values, first which is the state itself and second, a function to update the state. Note that unlike setState in class components that you may be used to, hooks state update method does not merge existing data. Therefore we have to take care of merging before passing the updated state. For this we define two functions handleAddItem, handleRemoveItem to add and remove items. Also note that these functions are passed down into our child components NewItem and ItemList as props. Now we have a basic but functional todo list. You can go ahead and introduce another state hook into NewItem component to capture the text input by user.

import React, { useState } from "react";
export function NewItem({ add }) {
const [text, setText] = useState("");
return (
<div className="Item">
<input
type="text"
placeholder="New Task"
value={text}
onChange={e => setText(e.target.value)}
></input>
<button onClick={() => add(text)}>Add</button>
</div>
);
}
// Rest of the components
view raw Items.js hosted with ❤ by GitHub

As you can see using hooks make our code a little bit cleaner and makes us avoid class components and life cycle hooks we may need to be concerned about. Moving forward with our goal of creating a redux like store, this let’s us abstract away state management logic and make it reusable. Specially useReducer hook which we will take a look at in a moment allows us to wrap this in a nice API.

Using React Context

Now let’s explore what react context is. React describes context as,

Context provides a way to pass data through the component tree without having to pass props down manually at every level.

This is exactly what we need for global state management. You start with a top level component that uses context to store global state. Then anywhere within your component tree you can access and/or make updates to this state. This is pretty much the basic idea behind global state managers like redux.

Remember we had to pass down the handleAddItem and handleRemoveItem methods as props to child components? Let’s refactor this to be obtained from the context without having to drill down the props.

Using react context is pretty easy. It takes the following form. First you create a context with a call to React.createContext() This takes an optional initial value as an argument. Then you need to provide the context somewhere in your component tree using Context.Provider so that components below that will have access to it. Then wherever you want to use the context, use Context.Consumer which will have access to the value.

const MyContext = React.createContext(/* initialValue /*)
<MyContext.Provider value={/* value*/}>
  <MyContext.Consumer>
    { value => /* components can access the value object */ }
  </MyContext.Consumer>
</MyContext.Provider>

A good explanation of React Context is available in the documentation

Lets start with creating a new context for our todos in contexts/TodoContext.js

import { createContext } from "react";
const TodoContext = createContext();
export default TodoContext;
view raw TodoContext.js hosted with ❤ by GitHub

Update the App component as follows to provide the TodoContext to our component tree.

import React, { useState } from "react";
import { ItemList, NewItem } from "./components/Items";
import TodoContext from "./contexts/TodoContext";
import "./App.css";
const initialItems = [
"Setup todo context",
"Consume context from child components"
];
function App() {
const [items, setItems] = useState(initialItems);
function handleAddItem(item) {
setItems([...items, item]);
}
function handleRemoveItem(index) {
const copy = [...items];
copy.splice(index, 1);
setItems(copy);
}
// We wrap the entire application with TodoContext provider, so that it
// provides the value defined here(items and handlers) to the entire component heirachy
// Now we don't need to pass the props to NewItem and ItemList
return (
<TodoContext.Provider
value={{ items, add: handleAddItem, remove: handleRemoveItem }}
>
<div className="App">
<header className="App-header">
<h2>🚀 ToDo App</h2>
<NewItem />
<ItemList />
</header>
</div>
</TodoContext.Provider>
);
}
export default App;
view raw App.js hosted with ❤ by GitHub

App.js

Next we can use the TodoContext.Consumer within our child components and have access to the state value passed to TodoContext.Provider

import React, { useState } from "react";
import TodoContext from "../contexts/TodoContext";
export function NewItem() {
const [text, setText] = useState("");
return (
<TodoContext.Consumer>
{values => (
<div className="Item">
<input
type="text"
placeholder="New Task"
value={text}
onChange={e => setText(e.target.value)}
></input>
<button onClick={() => values.add(text)}>Add</button>
</div>
)}
</TodoContext.Consumer>
);
}
export function ItemList() {
// Here we consume the values from TodoContext using the TodoContext Consumer
return (
<TodoContext.Consumer>
{values =>
values.items.map((item, i) => (
<Item text={item} index={i} key={i} remove={values.remove} />
))
}
</TodoContext.Consumer>
);
}
export function Item({ text, index, remove }) {
return (
<div className="Item">
{index + 1} {text}
<span onClick={() => remove(index)}>Done</span>
</div>
);
}
view raw Items.js hosted with ❤ by GitHub

Items.js

You may notice that we are repeating the TodoContext.Consumer wrapper everywhere we need to consume the context value. We can refactor this using the useContext() hook and make it less verbose.

import React, { useState, useContext } from "react";
import TodoContext from "../contexts/TodoContext";
export function NewItem() {
const [text, setText] = useState("");
// TodoContext is made available as a hook
const todoContext = useContext(TodoContext);
return (
<div className="Item">
<input
type="text"
placeholder="New Task"
value={text}
onChange={e => setText(e.target.value)}
></input>
<button onClick={() => todoContext.add(text)}>Add</button>
</div>
);
}
export function ItemList() {
const todoContext = useContext(TodoContext);
return todoContext.items.map((item, i) => (
<Item text={item} index={i} key={i} remove={todoContext.remove} />
));
}
// Rest
view raw Items.js hosted with ❤ by GitHub

Updated Items.js to use useContext

At the moment we are storing our global state in the App component. This is not a very desirable behavior specially as our todo state grows in complexity and it’s not exactly the responsibility of App component to hold the global state. So let’s move it to our already created TodoContext

import React, { createContext, useState, useContext } from "react";
export const TodoContext = createContext();
const initialItems = [
"Extract todo state to todo context",
"Implement todo provider"
];
// We wrap the provider in a nice little component
// which will hold the state and provide methods to
// update the state
function TodoProvider(props) {
const [items, setItems] = useState(initialItems);
function add(item) {
setItems([...items, item]);
}
function remove(index) {
const copy = [...items];
copy.splice(index, 1);
setItems(copy);
}
const todoData = { items, add, remove };
return <TodoContext.Provider value={todoData} {...props} />;
}
// Here we create a custom hook that allows us to consume
// the todo context
function useTodoContext() {
return useContext(TodoContext);
}
export { TodoProvider, useTodoContext };
view raw TodoContext.js hosted with ❤ by GitHub

contexts/TodoContext.js

We are exporting two functions here. One is a the TodoProvider component which is actually a higher order component wrapping the TodoContext.Provider along with a state. This becomes our global store and we need to update App component as follows.

import React, { useState } from "react";
import { ItemList, NewItem } from "./components/Items";
import { TodoProvider } from "./contexts/TodoContext";
import "./App.css";
function App() {
return (
<TodoProvider>
<div className="App">
<header className="App-header">
<h2>🚀 ToDo App</h2>
<NewItem />
<ItemList />
</header>
</div>
</TodoProvider>
);
}
export default App;
view raw App.js hosted with ❤ by GitHub

Our App.js is a lot more simplified and does not have todo logic in it.

The second export is simply a custom hook wrapping the useContext hook which already has TodoContext passed into it. In Items.js you need to import useTodoContext and replace,

const todoContext = useContext(TodoContext);

with

const todoContext = useTodoContext();

That’s it! Now we pretty much have a neat global store built with React Context and Hooks. Following the same pattern you can create new ContextProviders, wrap your application with it and then use a custom useContext hooks anywhere in your component hierarchy to use it as a store. Feel free to take a break at this point ☕

Adding Reducers and Actions

The following sections are heavily inspired by Redux. If you are not familiar with redux please checkout the documentation first.

Our state update logic is defined as functions in TodoProvider and each of these functions are stored as references in the state itself which can be accessed by consuming components to update the state. Following the redux pattern, we can introduce Actions and Reducers to our state manager. We can have actions that describe what happens to our state and a reducer which will handle state changes corresponding to the said actions.

Let’s start with creating the actions ADD_TODO, REMOVE_TODO and CLEAR_ALL. For now I’m going to add all the actions and the reducer inside the TodoContext.js file itself. If this gets too large feel free to split your code into separate files.

import React, { createContext, useReducer, useContext } from "react";
export const TodoContext = createContext();
// Initial state
const initialItems = [
"Extract todo state to todo context",
"Implement todo provider"
];
// Actions
export const ADD_TODO = "ADD_TODO";
export const REMOVE_TODO = "REMOVE_TODO";
export const CLEAR_ALL = "CLEAR_ALL";
// Action creators
export function addTodo(text) {
return { type: ADD_TODO, text };
}
export function removeTodo(index) {
return { type: REMOVE_TODO, index };
}
export function clearAll() {
return { type: CLEAR_ALL };
}
// Reducer
export function todoReducer(state, action) {
switch (action.type) {
case ADD_TODO:
return [...state, action.text];
case REMOVE_TODO:
const copy = [...state];
copy.splice(action.index, 1);
return copy;
case CLEAR_ALL:
return [];
default:
return state;
}
}
function TodoProvider(props) {
const [items, dispatch] = useReducer(todoReducer, initialItems);
const todoData = { items, dispatch };
return <TodoContext.Provider value={todoData} {...props} />;
}
function useTodoContext() {
return useContext(TodoContext);
}
export { TodoProvider, useTodoContext };
view raw TodoContext.js hosted with ❤ by GitHub

Updated TodoContext.js with actions and reducer

First I have created a few actions and corresponding action creators, pretty similar to redux. Then we have the reducer which is again a simple pure function which takes state and action as arguments and return the updated state.

Then inside our TodoProvider we are changing the useState hook to useReducer hook. It accepts a reducer and an initial state(unlike in redux where we pass the initial state to the reducer, it’s recommended to pass initial state into useReducer hook). The two values returned by useReducer is the state itself and a dispatch function which we can use to dispatch our actions. Since our consumer components would want to use the dispatch function we pass it as a value in TodoProvider. Now we are all set to use the state and dispatch actions from our consumer components.

import React, { useState } from "react";
import {
useTodoContext,
addTodo,
removeTodo,
clearAll
} from "../contexts/TodoContext";
export function NewItem() {
const [text, setText] = useState("");
// Get the dispatcher from TodoContext
const { dispatch } = useTodoContext();
// Dispatch addTodo action when adding a new item
return (
<div className="Item">
<input
type="text"
placeholder="New Task"
value={text}
onChange={e => setText(e.target.value)}
></input>
<button onClick={() => dispatch(addTodo(text))}>Add</button>
</div>
);
}
export function ItemList() {
const { items, dispatch } = useTodoContext();
return (
<>
{items.map((item, i) => (
<Item text={item} index={i} key={i} dispatch={dispatch} />
))}
{items.length > 0 && (
<p
style={{ fontSize: "15px", cursor: "pointer" }}
onClick={() => dispatch(clearAll())}
>
Clear All
</p>
)}
</>
);
}
export function Item({ text, index, dispatch }) {
return (
<div className="Item">
{index + 1} {text}
<span onClick={() => dispatch(removeTodo(index))}>Done</span>
</div>
);
}
view raw Items.js hosted with ❤ by GitHub

Updated Items.js to use actions and dipatcher

Notice how I have destructured the dispatch method from useTodoContext() and used it to dispatch an action of adding a todo. Similarly we use state value and dipatch along with relevant actions to list todos and remove todos.

Implement Authentication Using Context+Hooks Store

Now that we have a usable global store implementation, let’s go back to our main requirement and implement authentication. We need to have a separate context to store the authentication details. So our global state would look something like this.

{
  auth: {
    isLoggedIn: true,
    name: "John",
    error: null,
  },
  todos: []
}

We need to have routing configured with base route / displaying a login page and a protected route /todos which will display a Todos page if user is logged in. We can update our component hierarchy as follows. Todos component will handle all todos and live in /todo route which will be a private route. If user is not logged in he will be redirected to / route which will render the Login component.

alt

First add react-router and set up the components.

yarn add react-router-dom

import React from "react";
import { NewItem, ItemList } from "./Items";
export default function Todo() {
return (
<header className="App-header">
<h2>🚀 ToDo App</h2>
<NewItem />
<ItemList />
</header>
);
}
view raw Todos.js hosted with ❤ by GitHub

components/Todos.js

import React, { useState } from "react";
export default function Login() {
const [name, setName] = useState("");
const [loading, setLoading] = useState(false);
function handleLogin() {
// Handle login here
}
if (loading) return <p>Loading..</p>;
return (
<header className="App-header">
<h2>Login</h2>
<div className="Item">
<input
type="text"
placeholder="Name"
value={name}
onChange={e => setName(e.target.value)}
></input>
<button onClick={handleLogin}>Login</button>
</div>
</header>
);
}
view raw Login.js hosted with ❤ by GitHub

components/Login.js

import React from "react";
import Todos from "./components/Todos";
import Login from "./components/Login";
import { TodoProvider } from "./contexts/TodoContext";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import "./App.css";
function App() {
return (
<Router>
<div className="App">
<Greeting />
<Switch>
<Route path="/todos">
<TodoProvider>
<Todos />
</TodoProvider>
</Route>
<Route path="/">
<Login />
</Route>
</Switch>
</div>
</Router>
);
}
function Greeting() {
return <p>You are not logged in</p>;
}
export default App;
view raw App.js hosted with ❤ by GitHub

App.js

const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
// A fake authenticator to mock async api call
export async function apiLogin(name) {
await delay(2000);
if (name === "John") return true;
throw new Error("User not found!");
}
view raw auth.js hosted with ❤ by GitHub

api/auth.js

We can follow the same pattern we used for TodoContext to create AuthContext for authentication which is pretty straightforward and self explanatory.

import React, { createContext, useReducer, useContext } from "react";
export const AuthContext = createContext();
// Initial state
const initialState = {
isLoggedIn: false,
name: null,
error: null
};
// Actions
export const LOGIN_SUCCESS = "LOGIN_SUCCESS";
export const LOGIN_FAIL = "LOGIN_FAIL";
export const LOGOUT = "LOGOUT";
// Action creators
export function loginSuccess(name) {
return { type: LOGIN_SUCCESS, name };
}
export function loginFail(error) {
return { type: LOGIN_FAIL, error };
}
export function logout() {
return { type: LOGOUT };
}
// Reducer
export function authReducer(state, action) {
switch (action.type) {
case LOGIN_SUCCESS:
return { isLoggedIn: true, name: action.name, error: null };
case LOGIN_FAIL:
return { isLoggedIn: false, name: null, error: action.error };
case LOGOUT:
return { isLoggedIn: false };
default:
return state;
}
}
function AuthProvider(props) {
const [auth, dispatch] = useReducer(authReducer, initialState);
const authData = { auth, dispatch };
return <AuthContext.Provider value={authData} {...props} />;
}
function useAuthContext() {
return useContext(AuthContext);
}
export { AuthProvider, useAuthContext };
view raw AuthContext.js hosted with ❤ by GitHub

contexts/AuthContext.js

Before we use the AuthContext we need to make sure we are providing it at the top of our application. So let’s wrap the entire app with AuthProvider. Meanwhile I’m going to enhance our Greeting component as well to use the auth state and display a greeting and a logout button.

import React from "react";
import Todos from "./components/Todos";
import Login from "./components/Login";
import { TodoProvider } from "./contexts/TodoContext";
import { AuthProvider, useAuthContext, logout } from "./contexts/AuthContext";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import "./App.css";
function App() {
return (
<AuthProvider>
<Router>
<div className="App">
<Greeting />
<Switch>
<Route path="/todos">
<TodoProvider>
<Todos />
</TodoProvider>
</Route>
<Route path="/">
<Login />
</Route>
</Switch>
</div>
</Router>
</AuthProvider>
);
}
function Greeting() {
const { auth, dispatch } = useAuthContext();
if (auth.isLoggedIn)
return (
<p>
Hello, {auth.name}!
<button onClick={e => dispatch(logout())}>Logout</button>
</p>
);
return <p>You are not logged in</p>;
}
export default App;
view raw App.js hosted with ❤ by GitHub

App.js

Add Login Functionality

Now that we have auth store configured we can start building the functionality of Login page. Inside the login page we need to use the store to check whether the user is already logged in and if so, redirect him to the Todos page. If not, we display the login form and on submit we call our mocked login API. If the login is success we can dispatch the loginSuccess action or else dispatch loginFail action.

import React from "react";
import Todos from "./components/Todos";
import Login from "./components/Login";
import { TodoProvider } from "./contexts/TodoContext";
import { AuthProvider, useAuthContext, logout } from "./contexts/AuthContext";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import "./App.css";
function App() {
return (
<AuthProvider>
<Router>
<div className="App">
<Greeting />
<Switch>
<Route path="/todos">
<TodoProvider>
<Todos />
</TodoProvider>
</Route>
<Route path="/">
<Login />
</Route>
</Switch>
</div>
</Router>
</AuthProvider>
);
}
function Greeting() {
const { auth, dispatch } = useAuthContext();
if (auth.isLoggedIn)
return (
<p>
Hello, {auth.name}!
<button onClick={e => dispatch(logout())}>Logout</button>
</p>
);
return <p>You are not logged in</p>;
}
export default App;
view raw App.js hosted with ❤ by GitHub

Protect the Routes

Next let us make the /todos route private so that only a logged in user can access it. Anyone else will need to be redirected back to the login page. We can do this by simply wrapping the react-router Route component with a higher order component and using the AuthContext inside it to decide whether to render the route or redirect to login page.

import React from "react";
import { Route, Redirect } from "react-router-dom";
import { useAuthContext } from "../contexts/AuthContext";
export default function PrivateRoute({ children, ...rest }) {
const { auth } = useAuthContext();
return (
<Route
{...rest}
render={({ location }) =>
auth.isLoggedIn ? (
children
) : (
<Redirect to={{ pathname: "/", state: { from: location } }} />
)
}
/>
);
}
view raw PrivateRoute.js hosted with ❤ by GitHub

components/PrivateRoute.js

Now we can simply use PrivateRoute instead of Route to make any route inaccessible to logged out users.



And we are done! 🙌

We learnt how to build a redux like store gradually, using context and hooks and you can use this as a simple and lightweight alternative to redux in your next project. As next steps you can try experimenting with store middleware, checkout how to combine contexts(something like redux combineReducers()) as well as checkout the other hooks provided by react.