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:
- The current reducer state (acting as our store)
- 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:
- No external dependencies required
- TypeScript support out of the box
- Familiar Redux-like patterns
- Built-in side effect handling through useEffect
- 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.