Build your own Lightweight Redux Alternative with React Hooks

2023-12-03

Recently, I faced an interesting challenge while developing a reusable form component that needed to manage complex state and API interactions. While Redux would have been my go-to solution, I couldn't assume all projects using this component would have Redux installed.

This led me to explore combining useReducer and React Context to create a lightweight state management solution. Here's how I built it and why it works so well for modular components.

The Core Concept

The approach revolves around creating a Provider that exports:

  1. The current reducer state (acting as our store)
  2. The dispatch method from useReducer

This gives any child component access to both the global state and the ability to dispatch actions - essentially reimagining Redux's core functionality using built-in React features.

Implementation Guide

Let's build a practical example that demonstrates the key concepts. Our demo app will:

  • Track a counter value that can be incremented/decremented
  • Allow configurable step sizes for the counter
  • Make API calls when the counter changes
  • Store and display the API response data

Setting Up the Actions

First, let's define our action types and creators:

import { ICardDetails } from "./store.types";
 
export enum ActionType {
  IncrementValue = "counter/increment",
  DecrementValue = "counter/decrement",
  SetStepSize = "step/set",
  SetApiData = "api/set"
}
 
interface IIncrementValue {
  type: ActionType.IncrementValue;
}
 
interface IDecrementValue {
  type: ActionType.DecrementValue;
}
 
interface ISetStepSize {
  type: ActionType.SetStepSize;
  payload: number;
}
 
interface ISetApiData {
  type: ActionType.SetApiData;
  payload: ICardDetails;
}
 
export type Actions =
  | IIncrementValue
  | IDecrementValue
  | ISetStepSize
  | ISetApiData;
 
// Action creators
export const incrementValue = (): IIncrementValue => ({
  type: ActionType.IncrementValue
});
 
export const decrementValue = (): IDecrementValue => ({
  type: ActionType.DecrementValue
});
 
export const setStepSize = (value: number): ISetStepSize => ({
  type: ActionType.SetStepSize,
  payload: value
});
 
export const setApiData = (data: ICardDetails): ISetApiData => ({
  type: ActionType.SetApiData,
  payload: data
});

Creating the Store

Here's the core store implementation:

import React, { createContext, useReducer, useEffect } from "react";
import { setApiData, Actions, ActionType } from "./store.actions";
import { ICardDetails } from "./store.types";
 
interface IStoreState {
  value: number;
  stepSize: number;
  apiData: ICardDetails | null;
}
 
interface IAppContext {
  state: IStoreState;
  dispatch: React.Dispatch<Actions>;
}
 
const initialState: IStoreState = {
  value: 0,
  stepSize: 1,
  apiData: null
};
 
const store = createContext<IAppContext>({
  state: initialState,
  dispatch: () => null
});
 
const { Provider } = store;
 
const reducer = (state: IStoreState, action: Actions) => {
  const { value, stepSize } = state;
 
  switch (action.type) {
    case ActionType.IncrementValue:
      return {
        ...state,
        value: value + stepSize
      };
    case ActionType.DecrementValue:
      return {
        ...state,
        value: value - stepSize
      };
    case ActionType.SetStepSize:
      return {
        ...state,
        stepSize: action.payload
      };
    case ActionType.SetApiData:
      return {
        ...state,
        apiData: action.payload
      };
    default:
      return state;
  }
};
 
// Side effects handler
const useStoreEffects = (
  state: IStoreState,
  dispatch: React.Dispatch<Actions>
) => {
  useEffect(() => {
    fetch(`https://api.example.com/data/${state.value}`)
      .then(async (res) => {
        const data = await res.json();
        dispatch(setApiData(data));
      })
      .catch((err) => {
        console.error(`API call failed: ${err.message}`);
      });
  }, [state.value, dispatch]);
};
 
const AppProvider = ({ children }: { children: JSX.Element }) => {
  const [state, dispatch] = useReducer(reducer, initialState);
  useStoreEffects(state, dispatch);
  return <Provider value={{ state, dispatch }}>{children}</Provider>;
};
 
export { store, AppProvider };

Using the Store

Here's how to implement a component that uses our store:

import React, { useContext, useCallback } from "react";
import { store } from "../store/store";
import {
  incrementValue,
  decrementValue,
  setStepSize
} from "../store/store.actions";
 
const Counter = () => {
  const {
    state: { value, stepSize },
    dispatch
  } = useContext(store);
 
  const handleDecrement = useCallback(() => dispatch(decrementValue()), [dispatch]);
  const handleIncrement = useCallback(() => dispatch(incrementValue()), [dispatch]);
  const handleStepChange = useCallback(
    (event: React.ChangeEvent<HTMLSelectElement>) =>
      dispatch(setStepSize(Number(event.currentTarget.value))),
    [dispatch]
  );
 
  return (
    <div>
      <button onClick={handleDecrement}>-</button>
      <span>Current Value: {value}</span>
      <button onClick={handleIncrement}>+</button>
 
      <select value={stepSize} onChange={handleStepChange}>
        {[1, 2, 5, 10].map((step) => (
          <option key={step} value={step}>{step}</option>
        ))}
      </select>
    </div>
  );
};
 
export default Counter;

Key Benefits

This approach offers several advantages:

  1. No external dependencies required
  2. TypeScript support out of the box
  3. Familiar Redux-like patterns
  4. Built-in side effect handling through useEffect
  5. Excellent for modular components that need complex state management

Production Considerations

Before using this in production, consider adding:

  • Loading states
  • Error boundaries
  • Input validation
  • API call debouncing
  • Proper error handling
  • Unit tests for reducers and actions

This pattern has worked great for me in several projects where Redux would have been overkill. It's particularly useful for building self-contained components that need their own state management solution.