This article will cater to people that like to create their own solutions, and are interested in implementing something that resembles Redux with React Context. The version that I show here today will have support for Thunks, and the ability to log your actions and state. I won't go into too much detail on each section, but I will lay things out in a way that is very easy to understand. If you aren't too advanced with JavaScript yet, I honestly just recommend copy and pasting the code (lol).
I have already implemented this on a Next.js website, and it works very well. In theory however, you should be able to use this in any React application.
In order to make thing easy on everyone reading, we are going to build out our store in one file, then import it into the top level file of our application. You could easily break this down and use more than one file if that is what you prefer to do.
In a file called store.jsx
, I am going to lay down a little bit of code. Please ensure that your file supports React, here I am using the .jsx
extension.
For TypeScript you will probably most likely be using .tsx
import { createContext, useReducer, useMemo, useCallback } from 'react';
import exampleReducer, { exampleInitialState } from './reducers/example';
const combineReducers = (slices) => (state, action) =>
Object.keys(slices).reduce(
(acc, prop) => ({
...acc,
[prop]: slices[prop](acc[prop], action),
}),
state
);
export const StoreContext = createContext([{}, () => {}]);
// const initialState
// const rootReducer
export const StoreProvider = ({ children }) => {
const [state, dispatch] = useReducer(
rootReducer,
initialState
);
const store = useMemo(
() => [state, dispatch],
[state]
);
return (
<StoreContext.Provider value={store}> {children} </StoreContext.Provider>
);
};
We will leave this as is for now, and go make an example reducer. We will circle back and utilize the combineReducers
function shortly.
In a file reducers/example-reducer.js
, we will setup a basic reducer for our example
slice of state that contains an empty todos array.
export const exampleInitialState = {
todos: [],
};
export default function reducer(state, action) {
switch (action.type) {
case 'ADD_TO_DO':
return { ...state, todos: [...state.todos, ...action.payload] }
}
return state;
}
With that now setup, we can jump back to our store.js
file and utilize the combineReducers
function.
Let's hop back into our previous code, and do the rest of the setup.
import { createContext, useReducer, useMemo, useCallback } from 'react';
import exampleReducer, { exampleInitialState } from './reducers/example';
const combineReducers = (slices) => (state, action) =>
Object.keys(slices).reduce(
(acc, prop) => ({
...acc,
[prop]: slices[prop](acc[prop], action),
}),
state
);
export const StoreContext = createContext([{}, () => {}]);
const initialState = { example: exampleInitialState } // If you had additional reducers you would just add more properties to this object, passing in their initial state.
const rootReducer = combineReducers({ example: exampleReducer }) // If you had additional reducers, just add them to this object here.
export const StoreProvider = ({ children }) => {
const [state, dispatch] = useReducer(
rootReducer,
initialState
);
const store = useMemo(
() => [state, dispatch],
[state]
);
return (
<StoreContext.Provider value={store}> {children} </StoreContext.Provider>
);
};
This is all that we need right now to create our own Redux styled store. We can now go into the root entry point of our application, and utilize the
StoreProvider
that we created. We will get to the Thunk and logging support once we get something basic working.
This is very simple to do. For this example, I am using Next.js.
import Layout from '../components/Layout';
import { StoreProvider } from '../store';
import '../styles/globals.css';
function MyApp({ Component, pageProps }) {
return (
<StoreProvider>
<Layout>
<Component {...pageProps} />
</Layout>
</StoreProvider>
);
}
export default MyApp;
Just like that we now have our store installed into the application. Next, let's create an action that will add a todo to our example
slice of state.
We will setup our action in a file actions/example.js
.
export const addTodo = (todo) => ({
type: 'ADD_TODO',
payload: todo,
});
Now we should be able to call this in any of our React code, as long as you import the necessary stuff. Lets create a very simple page where we can import the store context, and dispatch the action to add the todos.
Let's pretend we have a pages
directory, there we will create a new page called todos.jsx
.
import { useState, useContext } from 'react';
import { StoreContext } from '../store';
import { addTodo } from '../actions/example';
export default function TodosPage() {
const [todo, setTodo] = useState('');
const [state, dispatch] = useContext(StoreContext);
return (
<div>
<input
type="text"
value={todo}
onChange={(e) => setTodo(e.target.value)}
/>
<button onClick={() => dispatch(addTodo(todo))}>Submit</button>
</div>
);
}
It's important to notice that wherever you use context anywhere else in the app, it will always be an array with the state of the store as the first element, and the dispatch function as the second element.
In order to be able to perform asynchronous behavior with our store, we need to utilize something similar to Redux Thunk.
Check out the augmentDispatch
function that has been added to our store.js
file.
import { createContext, useReducer, useMemo, useCallback } from 'react';
import exampleReducer, { exampleInitialState } from './reducers/example';
const combineReducers = (slices) => (state, action) =>
Object.keys(slices).reduce(
(acc, prop) => ({
...acc,
[prop]: slices[prop](acc[prop], action),
}),
state
);
export const StoreContext = createContext([{}, () => {}]);
const initialState = { example: exampleInitialState } // If you had additional reducers you would just add more properties to this object, passing in their initial state.
const rootReducer = combineReducers({ example: exampleReducer }) // If you had additional reducers, just add them to this object here.
const augmentDispatch =
(dispatch, state) => (input) =>
input instanceof Function ? input(dispatch, state) : dispatch(input)
export const StoreProvider = ({ children }) => {
const [state, dispatch] = useReducer(
rootReducer,
initialState
);
const store = useMemo(
() => [state, augmentDispatch(dispatch, state)],
[state]
);
return (
<StoreContext.Provider value={store}> {children} </StoreContext.Provider>
);
};
This is all we need for Thunk styled asynchronous actions! Just to ensure you understand it clearly, I will create a Thunk that fetches todos from an API.
In our actions/example.js
file, we will create the action that fetches todos from an API, then dispatches an action that sets the todos into our
example
slice of state.
export const addTodo = (todo) => ({
type: 'ADD_TODO',
payload: todo,
});
export const fetchTodos = () => async (dispatch, state) => {
return fetch('https://jsonplaceholder.typicode.com/todos')
.then((response) => response.json())
.then((json) => dispatch({ type: 'FETCH_TODOS', payload: json }));
}
Then all you need to do is add support for your new thunk in the example
reducer.
export const exampleInitialState = {
todos: [],
};
export default function reducer(state, action) {
switch (action.type) {
case 'ADD_TO_DO':
return { ...state, todos: [...state.todos, ...action.payload] }
case 'FETCH_TODOS':
return { ...state, todos: action.payload }
}
return state;
}
Congrats! You made it most of the way through the article. Before we sign off, let's hook up the logging functionality real quick.
All we need to do is go back into our store
file and lay down a little bit of code. Checkout the useLogger
custom React Hook that we created.
Here is our completed code.
import { createContext, useReducer, useMemo, useCallback } from 'react';
import exampleReducer, { exampleInitialState } from './reducers/example';
const getCurrentTimeFormatted = () => {
const currentTime = new Date();
const hours = currentTime.getHours();
const minutes = currentTime.getMinutes();
const seconds = currentTime.getSeconds();
const milliseconds = currentTime.getMilliseconds();
return `${hours}:${minutes}:${seconds}.${milliseconds}`;
};
const useLogger = (reducer) => {
const reducerWithLogger = useCallback(
(state, action) => {
const next = reducer(state, action);
console.group(
`%cAction: %c${action.type} %cat ${getCurrentTimeFormatted()}`,
'color: lightgreen; font-weight: bold;',
'color: black; font-weight: bold;',
'color: lightblue; font-weight: lighter;'
);
console.log(
'%cPrevious State:',
'color: #9E9E9E; font-weight: 700;',
state
);
console.log('%cAction:', 'color: #00A7F7; font-weight: 700;', action);
console.log('%cNext State:', 'color: #47B04B; font-weight: 700;', next);
console.groupEnd();
return next;
},
[reducer]
);
return reducerWithLogger;
};
const combineReducers = (slices) => (state, action) =>
Object.keys(slices).reduce(
(acc, prop) => ({
...acc,
[prop]: slices[prop](acc[prop], action),
}),
state
);
export const StoreContext = createContext([{}, () => {}]);
const initialState = { example: exampleInitialState } // If you had additional reducers you would just add more properties to this object, passing in their initial state.
const rootReducer = combineReducers({ example: exampleReducer }) // If you had additional reducers, just add them to this object here.
const augmentDispatch =
(dispatch, state) => (input) =>
input instanceof Function ? input(dispatch, state) : dispatch(input)
export const StoreProvider = ({ children }) => {
const logger = useLogger(rootReducer);
const [state, dispatch] = useReducer(
process.env.NODE_ENV === 'development' ? logger : rootReducer,
initialState
);
const store = useMemo(
() => [state, augmentDispatch(dispatch, state)],
[state]
);
return (
<StoreContext.Provider value={store}> {children} </StoreContext.Provider>
);
};
That's it for implementing a Redux style store with React Context. If you have any problems understanding any of this, feel free to shoot me a message, and I'll make sure it's crystal clear.