How to Build Your Own Redux State Management Implementation Based on Hooks
Introduced in React version 16.8 (February 2019), React Hooks adds support for state management and lifecycle events to functional components. Hooks lets you write React applications using only functional components. With Hooks, you get such powerful functionality, you might decide to migrate your codebase. In this blog post, we’ll explore the benefits of using Hooks in your ReactJS application. We’ll also implement our own “Redux” state management layer using Hooks, so you can see what you will need to do to migrate.
How You May Benefit from Hooks
A functional component in React is a JavaScript function that accepts props and returns a React element. Functional components are easier to read, debug, and test than class components. They also have better performance benefits. However, prior to the release of Hooks, functional components did not have state or lifecycle methods. Instead, if a developer needed state management or lifecycle methods, they had to use class components, which are significantly more complex.
Working with Hooks, you get all the benefits of class components together with the ease of functional components:
• Reusable code: You can isolate stateful management logic from the visual aspect of your components more easily. When you do, you can reuse that logic and test both parts separately. You can read more about this and see examples of custom hooks in the official React documentation.
• Improved code structure: You can restructure complex components more easily by splitting them into smaller pieces. This means you can avoid having non-related logic in traditional lifecycle events. (It’s very common to see a lot of different things for different purposes in functions like componentDidMount and componentDidUpdate.)
• Smaller and more performant applications: Function components transpile into smaller pieces of code than class components. You get smaller bundles and, as a result, faster loading applications.
• More testable code: By definition, function components are easier to test. This is because a pure function accepts N parameters and returns the same output given the same combination of inputs (this holds true as long as you implement your components as pure functions).
• More maintainable code: Function components are easier to read and easier to digest, especially for new developers joining an existing team with an existing code base. This benefit also applies for existing team members.
• Higher quality development processes: Taken all together, these benefits have a very positive impact on the overall development process. When you use Hooks in your codebase, you may achieve faster iterations, faster learning curves, higher quality development, improved user experiences, smaller error margins, and much more.
Build Your Own “Redux” State Management Using Hooks
As soon as Hooks launched, many development teams planned to migrate all their codebase from Redux, a popular state management tool frequently used with React, to Hooks. In this article, we will show how you can build your own “Redux” state management implementation using React Hooks.
About Our Sample Application
For this article, we created a sample app to manage a small soccer league. The app has three navigation levels:
• Main: shows a list of all soccer leagues
• Teams: shows a list of teams belonging to a specific league
• Players: shows a list of all the players belonging to a specific team
Simple, right? Nothing fancy.
Disclaimer: The code in this article is Gist-based, derived from the actual code base written TypeScript.
The application supports two different languages (Spanish and English). Language selection is a common enough feature. We will use the localization implementation in the step-by-step build process for the state management layer.
Here’s a screen grab of the app running. You can view the app’s functionality here.
Code Structure
If you’re familiar with Redux, you will recognize the “store” directory and its typical scaffold: an inner directory for each reducer and so on. We’ll follow the same structure in this example, but we will rename the “store” directory to “state”:
• Root of the project
• state
• localization
• types
• actions
• reducer
• middlewares
• selectors
• context
State Type
We’ll use the localization feature to build our “Redux” state management. First, we create the type of data we expect to handle on the localization reducer:
export interface LocalizationState { languageId: string; language?: any | null | undefined; languages: Language[]; }
We have just three properties:
• languageId: ID of the current language
• languages: list of available languages
• language: JSON object with all the translations for the current language
Actions
Once we know the data, we must define the actions we might use to modify the state. For this step, we create a custom hook “useActions” to define our actions. We pass the “dispatch” function that we get from the “useReducer” hook (covered later on) into the custom hook:
import { ACTION_TYPES } from './reducer'; import { LocalizationState } from './types'; export const useActions = (state: LocalizationState, dispatch: Function) => ({ updateLanguage: (languageId: string) => dispatch({ type: ACTION_TYPES.UPDATE_LANGUAGE, payload: languageId }), updateLanguages: (languages: Language[]) => dispatch({ type: ACTION_TYPES.UPDATE_LANGUAGES, payload: languages }) });
There are just two actions available to dispatch:
• updateLanguage: change the current language
• updateLanguages: feed the list of languages from the back-end
So far, these are simple steps.
Reducer
With state type and actions in place, let’s define our reducer:
import { LocalizationState } from './types'; const { REACT_APP_DEFAULT_LANGUAGE_ID: defaultLanguageId } = process.env; const initialState: LocalizationState = { languageId: defaultLanguageId || 'es-CR', language: undefined, languages: [] }; const ACTION_TYPES = { UPDATE_LANGUAGE: 'localization/UPDATE_LANGUAGE', FETCH_LANGUAGE: 'localization/FETCH_LANGUAGE', FETCH_LANGUAGE_REQUEST: 'localization/FETCH_LANGUAGE_REQUEST', FETCH_LANGUAGE_SUCCESS: 'localization/FETCH_LANGUAGE_SUCCESS', FETCH_LANGUAGE_FAILURE: 'localization/FETCH_LANGUAGE_FAILURE', UPDATE_LANGUAGES: 'localization/UPDATE_LANGUAGES' }; const reducer = (state = initialState, action: any) => { switch (action.type) { case ACTION_TYPES.UPDATE_LANGUAGE: return { ...state, languageId: action.payload }; case ACTION_TYPES.FETCH_LANGUAGE_SUCCESS: return { ...state, language: action.payload }; case ACTION_TYPES.UPDATE_LANGUAGES: return { ...state, languages: action.payload }; default: return state; } }; export { initialState, reducer, ACTION_TYPES };
As on a typical Redux reducer, this is only a function “listening” to actions and modifying the state accordingly.
Note that, although “updateLanguage” and “updateLanguages” are the two actions defined in our actions module, we are also listening for the “FETCH_LANGUAGE_SUCSESS” action, which won’t be dispatched directly. Instead, the middleware we create in the next step will dispatch it.
Middleware
import { ACTION_TYPES } from './reducer'; // This is not the Redux store, is the local storage of the device import store from 'store'; export const applyMiddleware = (dispatch: Function) => async (action: any) => { dispatch(action); if (action.type !== ACTION_TYPES.UPDATE_LANGUAGE) { return; } dispatch({ type: ACTION_TYPES.FETCH_LANGUAGE_REQUEST }); try { const url = `/locales/${action.payload}.json`; const response = await fetch(url); const language = await response.json(); dispatch({ type: ACTION_TYPES.FETCH_LANGUAGE_SUCCESS, payload: language }); store.set('languageId', action.payload); } catch (e) { dispatch({ type: ACTION_TYPES.FETCH_LANGUAGE_FAILURE, payload: e }); } };
Note the following:
• This middleware will use the dispatch function returned by the useReducer hook (see below).
• We’re doing more than just “hooking” this middleware to the “updateLanguage” action and executing the logic to load the corresponding translations for the selected language. We’re also storing the languageID on the device’s local storage so it can be reused when the user starts the application again. This makes it possible for the application to display all the on-screen text using the preferred language.
• This very simple scenario shows the power of middleware. Middleware was one of my main concerns when I started to see all the enthusiasm about moving from Redux to Hooks.
• One alternative to this approach is to update the languageID directly on the component. However, this method could make maintenance challenging. If you provide users with several places to manage their preferred language, usability suffers.
Selectors
Selectors are optional and defined by your own requirements. In this case, we have a few selectors, and the most important one resolves a localized string based on a key. Again, we create a custom hook:
import { LocalizationState } from './types'; export const useSelectors = (state: LocalizationState) => ({ localize: (key: string): string => { let localizedText = ''; // Fancy logic to resolve a localized text based on a key return ''; }, availableUnselectedLanguages: (): Language[] => { const { languageId: currentLanguageId } = state; return state.languages.filter(l => l.languageId !== currentLanguageId); }, activeLanguage: (): Language | undefined => { const { languageId: currentLanguageId } = state; return state.languages.find(l => l.languageId === currentLanguageId); } });
Context API
Now we’re ready for some fun! The next step in creating a “Redux” State Management layer doesn’t involve a typical Redux setup, so we’ll explain every step.
A key concept is the context API, which lets us share state to a specific part of an application. You can also share state to all levels of the application, if you expose the context provider at a very high level. This is what we’re doing in our example.
Let’s define the type of data our localization context will handle:
import { LocalizationState } from './types'; import { useActions } from './actions'; import { useSelectors } from './selectors'; type ContextType = { state: LocalizationState; actions: ReturnType<typeof useActions>; selectors: ReturnType<typeof useSelectors>; };
To provide a simple API to the developers from this localization context, we support three properties:
• state: the data itself
• actions: the set of actions that our custom “useActions” hook will provide
• selectors: selectors provided by our custom “useSelectors” hook
Because we’re using TypeScript and the context API requires an initial value satisfying the value type we just defined, we must create an initial instance with dummy functions for the actions and the selectors:
import React, { createContext } from 'react'; import { initialState } from './reducer'; import { LocalizationState } from './types'; import { useActions } from './actions'; import { useSelectors } from './selectors'; type ContextType = { state: LocalizationState; actions: ReturnType<typeof useActions>; selectors: ReturnType<typeof useSelectors>; }; const initialContext: ContextType = { state: { ...initialState }, actions: { updateLanguage: (languageId: string) => languageId, updateLanguages: (languages: Language[]) => languages }, selectors: { localize: (key: string): string => key, activeLanguage: () => undefined, availableUnselectedLanguages: () => [] } }; const LocalizationContext = createContext<ContextType>(initialContext);
With the context created and initialized, we create a component to wrap and expose the context provider (to use later on the App module):
const LocalizationProvider: FC<any> = ({ children }) => { const [state, dispatch] = useReducer(reducer, initialState); // Attach middleware to capture every dispatch const enhancedDispatch = applyMiddleware(dispatch); const actions = useActions(state, enhancedDispatch); const selectors = useSelectors(state); const contextValue = { state: { ...state }, actions: { ...actions }, selectors: { ...selectors } }; return ( <LocalizationContext.Provider value={contextValue}> {children} </LocalizationContext.Provider> ); }; export { LocalizationContext, LocalizationProvider };
Note the following things happening:
1. We create a “LocalizationProvider” component just to mix all the things and expose the provider of the context we just created.
2. We create our reducer using the “useReducer” hook which returns a state value and a dispatch function.
3. We “enhance” our dispatch function by applying the middleware we previously created.
4. We hook our actions and selectors by calling our custom “useActions” and “useSelectors” hooks.
5. We put together the state returned by the “useReducer” hook, the actions, and the selectors to get the final value we need to provide to our context.
6. We export both the context provider and the context itself. We will see how to use both within the next steps.
The final version of the context module looks like this:
import React, { FC, createContext, useReducer } from 'react'; import { initialState, reducer } from './reducer'; import { applyMiddleware } from './middleware'; import { useActions } from './actions'; import { useSelectors } from './selectors'; import { LocalizationState } from './types'; type ContextType = { state: LocalizationState; actions: ReturnType<typeof use Actions>; selectors: ReturnType<typeof useSelectors>; }; const initialContext: ContextType = { state: { ...initialState }, actions: { updateLanguage: (languageId: string) => languageId, updateLanguages: (languages: Language[]) => languages }, selectors: { localize: (key: string): string => key, activeLanguage: () => undefined, availableUnselectedLanguages: () => [] } }; const LocalizationContext = createContext<ContextType>(initialContext); const LocalizationProvider: FC<any> = ({ children }) => { const [state, dispatch] = useReducer(reducer, initialState); // Attach middleware to capture every dispatch const enhancedDispatch = applyMiddleware(dispatch); const actions = useActions(state, enhancedDispatch); const selectors = useSelectors(state); const contextValue = { state: { ...state }, actions: { ...actions }, selectors: { ...selectors } }; return ( <LocalizationContext.Provider value={contextValue}> {children} </LocalizationContext.Provider> ); }; export { LocalizationContext, LocalizationProvider };
Exposing the Context Provider
Now that we have the context and its provider, we make it available at the application scope. On the App module, import the LocalizationProvider we just created and make it available at the very top level:
import React from 'react'; import { LocalizationProvider } from './state/localization/context'; import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; // Components import Menu from './components/Menu'; // Pages import Leagues from './pages/Leagues/Leagues'; import League from './pages/League/League'; import Team from './pages/Team/Team'; const App: React.FC = () => ( <LocalizationProvider> <Router> <div id="app"> <Menu /> <Switch> <Route path="/" exact component={Leagues} /> <Route path="/league/:leagueId?" component={League} /> <Route path="/team/:teamId?" component={Team} /> </Switch> </div> </Router> </LocalizationProvider> ); export default App;
Note: In this example we use just one context, but you can create and expose all the different contexts you need in this same way.
Hooking Components to Contexts
You might think, “That context is wrapping my whole app! Will I need to re-render my entire app each time something changes in a context?” No, you don’t. We will re-render only those components that are hooked to the context that is changing. How? By hooking them using the context that we export, and by using the “useContext” hook, as the menu component does here:
import React, { FC, useContext } from 'react'; import { LocalizationContext } from '../state/localization/context'; const Menu: FC = () => { // Localization context const localizationContext = useContext(LocalizationContext); const { selectors: { localize }, actions: { updateLanguage }, state: { languages, languageId: currentLanguageId } } = localizationContext; const onLanguageClick = (language: Language) => { updateLanguage(language.languageId); }; return ( <> ... some UI components ... <Toolbar> <Typography>{localize('components.mainMenu.language')}</Typography></Toolbar> <Divider /> <List className={classes.list}> {languages.map(l => ( <ListItem button key={l._id} onClick={() => onLanguageClick(l)}><ListItemText primary={l.name} /> <ListItemIcon> <Checkbox edge="end" checked={l.languageId === currentLanguageId} /> </ListItemIcon> </ListItem> ))} </List> ... some UI components ... </> ); }; export default Menu;
Now you have your own “Redux” style state management in React, without using Redux.
Your Migration to Hooks: A Path to Improved Development
Our soccer league app example gives you a solid preview of all the benefits you can realize from Hooks. Reusable code, more performant applications, and improved overall quality of development efforts are just a few benefits.
React is well established, and the React roadmap is following patterns defined by Hooks. You’ll find that support for React Hooks is widespread and increasing every day, including with popular modules such as Axios, Apollo, React Router, and more.
You can be confident in a long-term investment in Hooks, and we recommend you consider migration. Consolidating practices, patterns, and architecture decisions could be a win for your development team and your entire organization.
Want to read more about React? Check out my last blog post on building an Instagram-like app using React Native!