39
A simple asynchronous alternative to React's useReducer
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.
There are multiple benefits of using useSimpleReducer over useReducer:
One of the most common patterns in front-end development is to:
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 running
count
will be updated when the state changes
error
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:
switch
statement. Instead of having to extract a payload
from our action object, we can use simple function parameters.
dispatch
function, we get back a set of callbacks actions
, one for each of our "actions".
And you get queuing, error handling, and type checking for free!
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.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.
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. 39