useSimpleReducer is the most boilerplate-free way possible to use reducers within a React component.
It’s a simple wrapper on top of useReducer and Redux Toolkit.
Why this is cool:
🌟 It makes the power of reducers as easy to use as useState.
🌟 Use reducers in all your components—it’s so lightweight.
Here’s a code example
import {useSimpleReducer} from 'use-simple-reducer'
const TextDoubler = () => {
const initialState = {text: ""}
const [state, {append, replace}, {doubled}] = useSimpleReducer({
initialState,
reducers: {
append: (newText: string) => (state) => { state.text += newText },
reset: () => (state) => (initialState)
}, selectors: {
doubled: () => (state) => (state.text + state.text)
}
})
return <div>
<p>Text: {state.text}</p>
<p>Doubled: {doubled()}</p>
<button onClick={() => append("hehe")}>Add letters</button>
<button onClick={() => reset()}>Reset</button>
</div>
}All of it is fully type-checked, and uses useReducer and Redux Toolkit under the hood.
Compare this with using:
useStateuseReducerwith Redux ToolkituseComplexState
Code snippet comparisons are below.
The point is making it easy & light-weight to use the Commands and Queries design pattern. State is always modified through explicit Command functions, and accessed through Query functions.
Install the package with:
npm install use-simple-reducer
Then just import it into your code:
import {useSimpleReducer} from "use-simple-reducer"Now you can use useSimpleReducer in components like this:
import {useSimpleReducer} from 'use-simple-reducer'
const TextDoubler = () => {
const initialState = {
text: ""
}
const [state, {append, replace}, {doubled}] = useSimpleReducer({
initialState,
reducers: {
append: (newText: string) => (state) => { state.text += newText },
reset: () => (state) => (initialState)
}, selectors: {
doubled: () => (state) => (state.text + state.text)
}
})
return <div>
<p>Text: {state.text}</p>
<p>Doubled: {doubled()}</p>
<button onClick={() => append("hehe")}>Add letters</button>
<button onClick={() => reset()}>Reset</button>
</div>
}useSimpleReducer takes a single object with the following fields
initialStatereducersselectors(optional)
reducers is an object of functions, each of the form
(...args) => (state) => { function_body_here }function_body_here can either
- return a value to replace
stateentirely (e.g.,return {...state, text: newText)) - or mutate
statedirectly (e.g.,state.text += newText).
Internally, useSimpleReducer uses Redux Toolkit and Immer to make sure state isn’t mutated directly, but rather a
modified copy is returned. If you’re not sure why that’s useful, read Immer’s documentation.
Similarly, selectors is an object of functions of the form
(...args) => (state) => { function_body_here }that returns a computed value based on state.
useSimpleReducer is fully typed. In the above code, Typescript will automatically infer the type of the (state)
parameter in your reducers and selectors as {text: string}.
If you want to be explicit about the State type, you can either use a type assertion:
type TodosState = { todos: string[] }
useSimpleReducer({
initialState: {todos: []} as TodosState,
...
})or extract initialState to an explicitly-typed variable:
type TodosState = { todos: string[] }
const initialState: TodosState = {todos: []}
useSimpleReducer({
initialState: initialState,
...
})Why this matters
You’ll want to use either of these methods when passing
initialStateisn’t enough to let Typescript infer the type of your state correctly. For example, if you passuseSimpleReducer({ initialState: {todos: []}, ... })Typescript doesn’t know what type
todosis an array of, and assumes it to beany[]. This will then throw an error when your reducers or selectors attempt to accesstodoslike an array of strings.
Why can’t you pass in a generics parameter like
useSimpleReducer<TodosState>(...)?You can, but
useSimpleReduceruses other generics parameters too, and if you specify one generics parameters, Typescript makes you specify the rest, which gets bulky.
You can pull out the useSimpleReducer(...) code into your own custom hook, to keep your components even cleaner and
better adhere to the Single Responsibility Principle.
import {useSimpleReducer} from 'use-simple-reducer'
const useTextDoubler = () => {
const initialState = {text: ""}
const reducers = {
append: (newText: string) => (state) => { state.text += newText },
reset: () => (state) => (initialState)
}
const selectors = {
doubled: () => (state) => (state.text + state.text)
}
return useSimpleReducer({initialState, reducers, selectors})
}
const TextDoubler = () => {
const [state, {append, replace}, {doubled}] = useTextDoubler()
return <div>
<p>Text: {state.text}</p>
<p>Doubled: {doubled()}</p>
<button onClick={() => append("hehe")}>Add letters</button>
<button onClick={() => reset()}>Reset</button>
</div>
}Consider the following code snippet written with useSimpleReducer
import {useSimpleReducer} from 'use-simple-reducer'
const TodosApp = () => {
type State = { todos: string[] }
const initialState: State = {todos: []}
const [state, {addTodo, setNthTodo}] = useSimpleReducer({
initialState,
reducers: {
addTodo: () => (state) => { state.todos.push(todo) },
setNthTodo: (index: number, todo: string) => (state) => { state.todos[index] = todo },
}, selectors: {
lastTodo: () => (state) => (state.todos.at(-1))
}
})
return <div>
<ol>
{state.todos.map((todo, i) => (<li key={i}>
<input value={todo} onChange={(event) => setNthTodo(i, event.target.value)}/>
</li>))}
</ol>
<button onClick={() => addTodo()}>Add todo</button>
</div>
}If it was written with useReducer and Redux Toolkit, it would look like:
import {useReducer} from 'react'
import {createSlice} from '@reduxjs/toolkit'
const TodosApp = () => {
type State = { todos: string[] }
const initialState: State = {todos: []}
const slice = createSlice({
name: "todos",
initialState,
reducers: {
addTodo: (state) => { state.todos.push(todo) },
setNthTodo: (state, index: number, todo: string) => { state.todos[i] = todo },
},
})
const [state, dispatch] = useReducer(slice.reducer, initialState)
return <div>
<ol>
{state.todos.map((todo, i) => (<li key={i}>
<input value={todo}
onChange={(event) => dispatch(slice.actions.setNthTodo({index: i, todo: event.target.value}))}/>
</li>))}
</ol>
<button onClick={() => dispatch(slice.actions.addTodo())}>Add todo</button>
</div>
}Notice the improvements with useSimpleReducer:
- There’s no need for a
namefield. Since we are not combining multiple slices together, like you would with redux, this is just unnecessary noise. - You pass
initialStatejust once instead of twice, and you can define it inline. - No need to wrap the actions with dispatches. That wrapping is ugly, noisy, and easy to mess up (no warning if you call the action without a dispatch—might be a confusing bug to debug).
- The actions (and selectors, if any) are returned right there in an easy to capture way.
- When actions take multiple arguments, you can pass them in naturally, like
setNthTodo(i, event.target.value), instead of having to wrap them in an object likesetNthTodo({index: i, todo: event.target.value}). This is also nice, because if you use IDE refactoring tools, they will rename the parameters correctly in the first case, but might miss the second case. - There’s built-in functionality for selectors.
If the code snippet was written with useComplexState (a similar library to help reduce
boilerplate), it would look like:
import {useComplexState} from 'use-complex-state'
const TodosApp = () => {
type State = { todos: string[] }
const initialState: State = {todos: []}
const [state, {addTodo, setNthTodo}] = useComplexState({
initialState,
reducers: {
addTodo: (state) => { state.todos.push(todo) },
setNthTodo: (state, index: number, todo: string) => { state.todos[index] = todo },
},
})
return <div>
<ol>
{state.todos.map((todo, i) => (<li key={i}>
<input value={todo} onChange={(event) => setNthTodo({index: i, todo: event.target.value})}/>
</li>))}
</ol>
<button onClick={() => addTodo()}>Add todo</button>
</div>
}While points 1–4 are addressed, points 5–6 are not.
If the code snippet was written with useState, it might look like:
import {produce} from 'immer'
const TodosApp = () => {
type State = { todos: string[] }
const initialState: State = {todos: []}
const [state, setState] = useState(initialState)
const addTodo = () => {
setState(produce(state, (draft) => { state.todos.push(todo) }))
}
const setNthTodo = (index: number, todo: string) => {
setState(produce(state, (draft) => { state.todos[index] = todo }))
}
const lastTodo = () => (state.todos.at(-1))
return <div>
<ol>
{state.todos.map((todo, i) => (<li key={i}>
<input value={todo} onChange={(event) => setNthTodo(i, event.target.value)}/>
</li>))}
</ol>
<button onClick={() => addTodo()}>Add todo</button>
</div>
}In React, it’s important not to mutate state directly, but rather to use setState. Thus, you would either have to wrap
your action function with produce(...) from Immer, or make sure it doesn’t accidentally mutate the state. This is
noisy and easy to get wrong (can lead to difficult to debug behaviours).
In comparison, useSimpleReducer makes it clean and safe to work with state. It also groups together the functionality
around a bit of state in a convenient way.