Even though React's useReducer has gained a lot of popularity during the last couple of years, it can be difficult to use for some common cases. Specifically, it requires a lot of boilerplate to support async actions.
Sure, there are multiple ways of performing side effects/ async actions with useReducer such as using a useEffect or maybe making use of other libraries that extend the useReducer hook, either by depending on thunks or async action handlers to support such functionality.
But there is always a simpler and better way. useSimpleReducer
offers an approach that is more intuitive and less verbose making it easier to create asynchronous actions.
Use it today by installing it from its NPM package.
npm i @bitovi/use-simple-reducer
Or try a working demo here.
The problems use-simple-reducer solves
There are multiple benefits of using useSimpleReducer over useReducer:
- Easy to create async actions
- Less boilerplate code
- Error handling and recovery
- Built-in type checking
Easy to create asynchronous actions
One of the most common patterns in front-end development is to:
- Asynchronously update the server upon some user action (ex: clicking a button)
- Show that the server is being updated (ex: a spinner or a disabled action button)
- Show the updated state when the action completes.
- Return an error if the async action fails
A simple case is a counter. You want your JSX to look like this:
<div>
<button onClick={() => add(2)}>Add</button>
<div>
<p>Steps: {count}</p>
<div>{isActive ? <Loader /> : "Processing completed"}</div>
{error && <p>Error: {error}</p>}
</div>
</div>
Where:
add
async updates the serverisActive
displays a spinner while the action is runningcount
will be updated when the state changeserror
will be of a non null value if the async action failed
BUT … this is HARD with useReducer
A useReducer implementation might look something like:
type ActionType =
| { type: "LOADING" }
| { type: "ADD_SUCCESS", payload: number }
| { type: "ADD_FAILURE", payload: any };
type StateType = {
count: number,
isActive: boolean,
error: any,
};
const initialState = {
count: 0,
isActive: false,
error: null,
};
function Counter() {
const [{count, isActive, error}, dispatch] = useReducer(
(state: StateType, action: ActionType) => {
switch (action.type) {
case "LOADING":
return {
...state,
isActive: true,
};
case "ADD_SUCCESS":
return {
...state,
count: state.count + action.payload,
isActive: false,
error: null,
};
case "ADD_FAILURE":
return {
...state,
isActive: false,
error: action.payload,
};
default:
return state;
}
},
initialState
);
const add = (amount: number) => {
dispatch({ type: "LOADING" });
// An api call to update the count state on the server
updateCounterOnServer(state.count + amount)
.then(() => {
dispatch({ type: "ADD_SUCCESS", payload: amount });
})
.catch((error) => {
dispatch({ type: "ADD_FAILURE", payload: error });
});
};
return (
<div>
<button onClick={() => add(2)}>Add</button>
<div>
<p>Steps: {count}</p>
<div>{isActive ? <Loader /> : "Processing completed"}</div>
{error && <p>Error: {error}</p>}
</div>
</div>
);
}
This is much more simple with useSimpleReducer:
type CounterState = { count: number };
const initialState = {
count: 0,
};
function Counter() {
const [state, actions, queue, error] = useSimpleReducer(
// initial state
initialState,
// collection of reducer methods
{
async add(state: CounterState, amount: number) {
// An api call to update the count state on the server
await updateCounterOnServer(state.count + amount);
return { ...state, count: state.count + amount };
},
}
);
return (
<div>
<button onClick={() => actions.add(2)}>Add</button>
<div>
<p>Steps: {state.count}</p>
<div>{queue.isActive ? <Loader /> : "Processing completed"}</div>
{error && <p>{error.reason}</p>}
</div>
</div>
);
}
Looks quite a bit cleaner, right? Here is why:
- Logic is now encapsulated in separate methods, rather than in one giant
switch
statement. Instead of having to extract apayload
from our action object, we can use simple function parameters. - Instead of getting back a one-size-fits-all
dispatch
function, we get back a set of callbacksactions
, one for each of our "actions".
And you get queuing, error handling, and type checking for free!
Queueing
Instead of dispatching actions, the user can use the actions
value to call the reducer methods provided.
Any invoked reducer action gets added to a queue. The queue will then start processing those asynchronous actions in the same order they have been added.
An queue.isActive
flag indicates whether the queue is currently processing any actions or not.
A set of values queue.runningAction
and queue.pendingActions
are also exposed that can be used for debugging the current state of the queue.
Error handling
The useSimpleReducer
hook returns an error
if any of the reducer methods fail.
This error object exposes a number of recovery methods that provide the flexibility for the user to run the failed action, pending actions, or all of them.
return (
<div>
<button onClick={()=> actions.add(2)}>Add</button>
<div>
<p>Steps: {state.count}</p>
<div>{queue.isActive ? : "Processing completed"}</div>
</div>
{error && <AlertDialog content={error.reason} onConfirm={() => error.runFailedAction()} />}
</div>
);
An in-depth explanation of these values can be found in the API documentation on Github.
Final thoughts
I know it's a very common pattern in the industry to use a useReducer
. But I believe that useSimpleReducer
does it better in a way that is more intuitive to understand while offering extra capabilities.
You can try out the demo or install the package from NPM.