Good day, friends!
I bring to your attention a simple application - a list of tasks. What is special about it, you ask. The point is that I tried to implement the same trick using four different approaches to managing state in React applications: useState, useContext + useReducer, Redux Toolkit, and Recoil.
Let's start with what the state of an application is and why choosing the right tool for working with it is so important.
State is a collective term for any information related to an application. This can be both data used in the application, such as the same task list or user list, or state as such, such as the loading state or the state of a form.
Conditionally, the state can be divided into local and global. A local state usually refers to the state of an individual component, for example, the state of a form, as a rule, is the local state of the corresponding component. In turn, the global state is more correctly called distributed or shared, meaning that such a state is used by more than one component. The conditionality of the gradation in question is expressed in the fact that the local state may well be used by several components (for example, the state defined using useState () can be passed to child components as props), and the global state is not necessarily used by all application components (for example, in Redux where there is one store for the state of the entire application, usually,a separate slice of the state is created for each part of the UI, more precisely, for the control logic of this part).
The importance of choosing the right tool for managing application state stems from the problems that arise when a tool does not match the size of the application or the complexity of the logic it implements. We'll see this as we develop the to-do list.
I will not go into the details of the operation of each tool, but will limit myself to a general description and links to relevant materials. For UI prototyping, react-bootstrap will be used .
Code on GitHub
Sandbox on CodeSandbox
Create a project using Create React App:
yarn create react-app state-management
#
npm init react-app state-management
#
npx create-react-app state-management
Install dependencies:
yarn add bootstrap react-bootstrap nanoid
#
npm i bootstrap react-bootstrap nanoid
- bootstrap, react-bootstrap - styles
- nanoid - utility for generating a unique ID
In src, create a "use-state" directory for the first version of the tudushka.
useState ()
Hooks Cheat Sheet
The useState () hook is for managing the local state of a component. It returns an array with two elements: the current state value and a setter function to update this value. The signature of this hook is:
const [state, setState] = useState(initialValue)
- state - the current value of the state
- setState - setter
- initialValue - initial or default value
One of the advantages of array destructuring, as opposed to object destructuring, is the ability to use arbitrary variable names. By convention, the name of the setter must start with "set" + the name of the first element with a capital letter ([count, setCount], [text, setText], etc.).
For now, we will restrict ourselves to four basic operations: add, switch (execute), update and delete a task, but let's complicate our life by the fact that our initial state will be in the form of normalized data (this will allow us to practice immutable updating properly).
Project structure:
|--use-state |--components |--index.js |--TodoForm.js |--TodoList.js |--TodoListItem.js |--App.js
I think everything is clear here.
In App.js, we use useState () to define the initial state of the application, import and render application components, passing them the state and setter as props:
//
import { useState } from 'react'
//
import { TodoForm, TodoList } from './components'
//
import { Container } from 'react-bootstrap'
//
// ,
const initialState = {
todos: {
ids: ['1', '2', '3', '4'],
entities: {
1: {
id: '1',
text: 'Eat',
completed: true
},
2: {
id: '2',
text: 'Code',
completed: true
},
3: {
id: '3',
text: 'Sleep',
completed: false
},
4: {
id: '4',
text: 'Repeat',
completed: false
}
}
}
}
export default function App() {
const [state, setState] = useState(initialState)
const { length } = state.todos.ids
return (
<Container style={{ maxWidth: '480px' }} className='text-center'>
<h1 className='mt-2'>useState</h1>
<TodoForm setState={setState} />
{length ? <TodoList state={state} setState={setState} /> : null}
</Container>
)
}
In TodoForm.js, we are implementing adding a new task to the list:
//
import { useState } from 'react'
// ID
import { nanoid } from 'nanoid'
//
import { Container, Form, Button } from 'react-bootstrap'
//
export const TodoForm = ({ setState }) => {
const [text, setText] = useState('')
const updateText = ({ target: { value } }) => {
setText(value)
}
const addTodo = (e) => {
e.preventDefault()
const trimmed = text.trim()
if (trimmed) {
const id = nanoid(5)
const newTodo = { id, text, completed: false }
// ,
setState((state) => ({
...state,
todos: {
...state.todos,
ids: state.todos.ids.concat(id),
entities: {
...state.todos.entities,
[id]: newTodo
}
}
}))
setText('')
}
}
return (
<Container className='mt-4'>
<h4>Form</h4>
<Form className='d-flex' onSubmit={addTodo}>
<Form.Control
type='text'
placeholder='Enter text...'
value={text}
onChange={updateText}
/>
<Button variant='primary' type='submit'>
Add
</Button>
</Form>
</Container>
)
}
In TodoList.js, we just render the list of items:
//
import { TodoListItem } from './TodoListItem'
//
import { Container, ListGroup } from 'react-bootstrap'
// ,
//
// ,
export const TodoList = ({ state, setState }) => (
<Container className='mt-2'>
<h4>List</h4>
<ListGroup>
{state.todos.ids.map((id) => (
<TodoListItem
key={id}
todo={state.todos.entities[id]}
setState={setState}
/>
))}
</ListGroup>
</Container>
)
Finally, the fun part happens in TodoListItem.js - here we implement the remaining operations: switching, updating and deleting a task:
//
import { ListGroup, Form, Button } from 'react-bootstrap'
//
export const TodoListItem = ({ todo, setState }) => {
const { id, text, completed } = todo
//
const toggleTodo = () => {
setState((state) => {
//
const { todos } = state
return {
...state,
todos: {
...todos,
entities: {
...todos.entities,
[id]: {
...todos.entities[id],
completed: !todos.entities[id].completed
}
}
}
}
})
}
//
const updateTodo = ({ target: { value } }) => {
const trimmed = value.trim()
if (trimmed) {
setState((state) => {
const { todos } = state
return {
...state,
todos: {
...todos,
entities: {
...todos.entities,
[id]: {
...todos.entities[id],
text: trimmed
}
}
}
}
})
}
}
//
const deleteTodo = () => {
setState((state) => {
const { todos } = state
const newIds = todos.ids.filter((_id) => _id !== id)
const newTodos = newIds.reduce((obj, id) => {
if (todos.entities[id]) return { ...obj, [id]: todos.entities[id] }
else return obj
}, {})
return {
...state,
todos: {
...todos,
ids: newIds,
entities: newTodos
}
}
})
}
//
const inputStyles = {
outline: 'none',
border: 'none',
background: 'none',
textAlign: 'center',
textDecoration: completed ? 'line-through' : '',
opacity: completed ? '0.8' : '1'
}
return (
<ListGroup.Item className='d-flex align-items-baseline'>
<Form.Check
type='checkbox'
checked={completed}
onChange={toggleTodo}
/>
<Form.Control
style={inputStyles}
defaultValue={text}
onChange={updateTodo}
disabled={completed}
/>
<Button variant='danger' onClick={deleteTodo}>
Delete
</Button>
</ListGroup.Item>
)
}
In components / index.js, we re-export the components:
export { TodoForm } from './TodoForm'
export { TodoList } from './TodoList'
The scr / index.js file looks like this:
import React from 'react'
import { render } from 'react-dom'
//
import 'bootstrap/dist/css/bootstrap.min.css'
//
import App from './use-state/App'
const root$ = document.getElementById('root')
render(<App />, root$)
The main problems of this approach to state management:
- The need to transfer state and / or setter at each nesting level due to the local nature of the state
- The logic for updating the state of the application is scattered across the components and mixed with the logic of the components themselves
- Complexity of state renewal arising from its immutability
- Unidirectional data flow, the impossibility of free exchange of data between components located at the same nesting level, but in different subtrees of the virtual DOM
The first two problems can be solved with the useContext () / useReducer () combination.
useContext () + useReducer ()
Hooks Cheat Sheet
Context allows passing values ββto child components directly, bypassing their ancestors. The useContext () hook allows you to retrieve values ββfrom the context in any component wrapped in a provider.
Creating a context:
const TodoContext = createContext()
Providing stateful context to child components:
<TodoContext.Provider value={state}>
<App />
</TodoContext.Provider>
Extracting the state value from the context in a component:
const state = useContext(TodoContext)
The useReducer () hook accepts a reducer and an initial state. It returns the value of the current state and a function for dispatching operations based on which the state is updated. The signature of this hook is:
const [state, dispatch] = useReducer(todoReducer, initialState)
The state update algorithm looks like this: the component sends the operation to the reducer, and the reducer, based on the operation type (action.type) and the optional operation payload (action.payload), changes the states in a certain way.
The combination of useContext () and useReducer () results in the ability to pass the state and dispatcher returned by useReducer () to any component that is a descendant of a context provider.
Create the "use-reducer" directory for the second version of the trick. Project structure:
|--use-reducer |--modules |--components |--index.js |--TodoForm.js |--TodoList.js |--TodoListItem.js |--todoReducer |--actions.js |--actionTypes.js |--todoReducer.js |--todoContext.js |--App.js
Let's start with the gearbox. In actionTypes.js, we simply define the types (names, constants) of the operations:
const ADD_TODO = 'ADD_TODO'
const TOGGLE_TODO = 'TOGGLE_TODO'
const UPDATE_TODO = 'UPDATE_TODO'
const DELETE_TODO = 'DELETE_TODO'
export { ADD_TODO, TOGGLE_TODO, UPDATE_TODO, DELETE_TODO }
Operation types are defined in a separate file, since they are used both when creating operation objects and when choosing a case reducer in a switch statement. There is another approach where the types, the creators of the operation and the reducer are placed in the same file. This approach is called the "duck" file structure.
Actions.js defines the so-called action creators, which return objects of a certain shape (for the reducer):
import { ADD_TODO, TOGGLE_TODO, UPDATE_TODO, DELETE_TODO } from './actionTypes'
const createAction = (type, payload) => ({ type, payload })
const addTodo = (newTodo) => createAction(ADD_TODO, newTodo)
const toggleTodo = (todoId) => createAction(TOGGLE_TODO, todoId)
const updateTodo = (payload) => createAction(UPDATE_TODO, payload)
const deleteTodo = (todoId) => createAction(DELETE_TODO, todoId)
export { addTodo, toggleTodo, updateTodo, deleteTodo }
The reducer itself is defined in todoReducer.js. Once again, the reducer takes the application state and the operation dispatched from the component and, based on the operation type (and payload), performs certain actions that result in the state being updated. Updating the state is performed in the same way as in the previous version of the trick, only instead of setState () the reducer returns a new state.
// ID
import { nanoid } from 'nanoid'
//
import * as actions from './actionTypes'
export const todoReducer = (state, action) => {
const { todos } = state
switch (action.type) {
case actions.ADD_TODO: {
const { payload: newTodo } = action
const id = nanoid(5)
return {
...state,
todos: {
...todos,
ids: todos.ids.concat(id),
entities: {
...todos.entities,
[id]: { id, ...newTodo }
}
}
}
}
case actions.TOGGLE_TODO: {
const { payload: id } = action
return {
...state,
todos: {
...todos,
entities: {
...todos.entities,
[id]: {
...todos.entities[id],
completed: !todos.entities[id].completed
}
}
}
}
}
case actions.UPDATE_TODO: {
const { payload: id, text } = action
return {
...state,
todos: {
...todos,
entities: {
...todos.entities,
[id]: {
...todos.entities[id],
text
}
}
}
}
}
case actions.DELETE_TODO: {
const { payload: id } = action
const newIds = todos.ids.filter((_id) => _id !== id)
const newTodos = newIds.reduce((obj, id) => {
if (todos.entities[id]) return { ...obj, [id]: todos.entities[id] }
else return obj
}, {})
return {
...state,
todos: {
...todos,
ids: newIds,
entities: newTodos
}
}
}
// ( case)
default:
return state
}
}
TodoContext.js defines the initial state of the application, creates and exports a context provider with a state value and a dispatcher from useReducer ():
// react
import { createContext, useReducer, useContext } from 'react'
//
import { todoReducer } from './todoReducer/todoReducer'
//
const TodoContext = createContext()
//
const initialState = {
todos: {
ids: ['1', '2', '3', '4'],
entities: {
1: {
id: '1',
text: 'Eat',
completed: true
},
2: {
id: '2',
text: 'Code',
completed: true
},
3: {
id: '3',
text: 'Sleep',
completed: false
},
4: {
id: '4',
text: 'Repeat',
completed: false
}
}
}
}
//
export const TodoProvider = ({ children }) => {
const [state, dispatch] = useReducer(todoReducer, initialState)
return (
<TodoContext.Provider value={{ state, dispatch }}>
{children}
</TodoContext.Provider>
)
}
//
export const useTodoContext = () => useContext(TodoContext)
In this case, src / index.js looks like this:
// React, ReactDOM
import { TodoProvider } from './use-reducer/modules/TodoContext'
import App from './use-reducer/App'
const root$ = document.getElementById('root')
render(
<TodoProvider>
<App />
</TodoProvider>,
root$
)
Now we don't need to pass the state and function to update it at every level of component nesting. The component retrieves the state and the dispatcher using useTodoContext (), for example:
import { useTodoContext } from '../TodoContext'
//
const { state, dispatch } = useTodoContext()
Operations are dispatched to the reducer using dispatch (), to which the creator of the operation is passed, to which the payload can be passed:
import * as actions from '../todoReducer/actions'
//
dispatch(actions.addTodo(newTodo))
Component code
App.js:
TodoForm.js:
TodoList.js:
TodoListItem.js:
// components
import { TodoForm, TodoList } from './modules/components'
// styles
import { Container } from 'react-bootstrap'
// context
import { useTodoContext } from './modules/TodoContext'
export default function App() {
const { state } = useTodoContext()
const { length } = state.todos.ids
return (
<Container style={{ maxWidth: '480px' }} className='text-center'>
<h1 className='mt-2'>useReducer</h1>
<TodoForm />
{length ? <TodoList /> : null}
</Container>
)
}
TodoForm.js:
// react
import { useState } from 'react'
// styles
import { Container, Form, Button } from 'react-bootstrap'
// context
import { useTodoContext } from '../TodoContext'
// actions
import * as actions from '../todoReducer/actions'
export const TodoForm = () => {
const { dispatch } = useTodoContext()
const [text, setText] = useState('')
const updateText = ({ target: { value } }) => {
setText(value)
}
const handleAddTodo = (e) => {
e.preventDefault()
const trimmed = text.trim()
if (trimmed) {
const newTodo = { text, completed: false }
dispatch(actions.addTodo(newTodo))
setText('')
}
}
return (
<Container className='mt-4'>
<h4>Form</h4>
<Form className='d-flex' onSubmit={handleAddTodo}>
<Form.Control
type='text'
placeholder='Enter text...'
value={text}
onChange={updateText}
/>
<Button variant='primary' type='submit'>
Add
</Button>
</Form>
</Container>
)
}
TodoList.js:
// components
import { TodoListItem } from './TodoListItem'
// styles
import { Container, ListGroup } from 'react-bootstrap'
// context
import { useTodoContext } from '../TodoContext'
export const TodoList = () => {
const {
state: { todos }
} = useTodoContext()
return (
<Container className='mt-2'>
<h4>List</h4>
<ListGroup>
{todos.ids.map((id) => (
<TodoListItem key={id} todo={todos.entities[id]} />
))}
</ListGroup>
</Container>
)
}
TodoListItem.js:
// styles
import { ListGroup, Form, Button } from 'react-bootstrap'
// context
import { useTodoContext } from '../TodoContext'
// actions
import * as actions from '../todoReducer/actions'
export const TodoListItem = ({ todo }) => {
const { dispatch } = useTodoContext()
const { id, text, completed } = todo
const handleUpdateTodo = ({ target: { value } }) => {
const trimmed = value.trim()
if (trimmed) {
dispatch(actions.updateTodo({ id, trimmed }))
}
}
const inputStyles = {
outline: 'none',
border: 'none',
background: 'none',
textAlign: 'center',
textDecoration: completed ? 'line-through' : '',
opacity: completed ? '0.8' : '1'
}
return (
<ListGroup.Item className='d-flex align-items-baseline'>
<Form.Check
type='checkbox'
checked={completed}
onChange={() => dispatch(actions.toggleTodo(id))}
/>
<Form.Control
style={inputStyles}
defaultValue={text}
onChange={handleUpdateTodo}
disabled={completed}
/>
<Button variant='danger' onClick={() => dispatch(actions.deleteTodo(id))}>
Delete
</Button>
</ListGroup.Item>
)
}
Thus, we have solved the first two problems associated with using useState () as a tool for managing state. In fact, with the help of an interesting library, we can solve the third problem - the complexity of updating the state. immer allows you to safely mutate immutable values ββ(yes, I know how that sounds), just wrap the reducer in a "produce ()" function. Let's create a file "todoReducer / todoProducer.js":
// , immer
import produce from 'immer'
import { nanoid } from 'nanoid'
//
import * as actions from './actionTypes'
// ""
// draft -
export const todoProducer = produce((draft, action) => {
const {
todos: { ids, entities }
} = draft
switch (action.type) {
case actions.ADD_TODO: {
const { payload: newTodo } = action
const id = nanoid(5)
ids.push(id)
entities[id] = { id, ...newTodo }
break
}
case actions.TOGGLE_TODO: {
const { payload: id } = action
entities[id].completed = !entities[id].completed
break
}
case actions.UPDATE_TODO: {
const { payload: id, text } = action
entities[id].text = text
break
}
case actions.DELETE_TODO: {
const { payload: id } = action
ids.splice(ids.indexOf(id), 1)
delete entities[id]
break
}
default:
return draft
}
})
The main limitation immer imposes is that we must either mutate the state directly or return a state that has been updated immutably. You cannot do both at the same time.
We make changes to todoContext.js:
// import { todoReducer } from './todoReducer/todoReducer'
import { todoProducer } from './todoReducer/todoProducer'
//
// const [state, dispatch] = useReducer(todoReducer, initialState)
const [state, dispatch] = useReducer(todoProducer, initialState)
Everything works as before, but the reducer code is now easier to read and parse.
Moving on.
Redux Toolkit
The Redux Toolkit Guide The
Redux Toolkit is a collection of tools that make it easy to work with Redux. Redux itself is very similar to what we implemented with useContext () + useReducer ():
- The state of the entire application is in one store
- Child components are wrapped in a Provider from react-redux , to which the store is passed as a "store" prop
- The reducers of each part of the state are combined using combineReducers () into a single root reducer, which is passed to createStore () when the store is created.
- Components are connected to the store using connect () (+ mapStateToProps (), mapDispatchToProps ()), etc.
To implement the basic operations, we will use the following utilities from the Redux Toolkit:
- configureStore () - for creating and configuring the store
- createSlice () - to create pieces of state
- createEntityAdapter () - to create an entity adapter
A little later, we will expand the functionality of the task list using the following utilities:
- createSelector () - for creating selectors
- createAsyncThunk () - to create thunk
Also in the components we will use the following hooks from react-redux: "useDispatch ()" - to get access to the dispatcher and "useSelector ()" - to get access to the selectors.
Create a directory "redux-toolkit" for the third version of the twist. Install Redux Toolkit:
yarn add @reduxjs/toolkit
#
npm i @reduxjs/toolkit
Project structure:
|--redux-toolkit |--modules |--components |--index.js |--TodoForm.js |--TodoList.js |--TodoListItem.js |--slices |--todosSlice.js |--App.js |--store.js
Let's start with the repository. store.js:
//
import { configureStore } from '@reduxjs/toolkit'
//
import todosReducer from './modules/slices/todosSlice'
//
const preloadedState = {
todos: {
ids: ['1', '2', '3', '4'],
entities: {
1: {
id: '1',
text: 'Eat',
completed: true
},
2: {
id: '2',
text: 'Code',
completed: true
},
3: {
id: '3',
text: 'Sleep',
completed: false
},
4: {
id: '4',
text: 'Repeat',
completed: false
}
}
}
}
//
const store = configureStore({
reducer: {
todos: todosReducer
},
preloadedState
})
export default store
In this case, src / index.js looks like this:
// React, ReactDOM &
//
import { Provider } from 'react-redux'
//
import App from './redux-toolkit/App'
//
import store from './redux-toolkit/store'
const root$ = document.getElementById('root')
render(
<Provider store={store}>
<App />
</Provider>,
root$
)
We pass to the gearbox. slices / todosSlice.js:
//
import {
createSlice,
createEntityAdapter
} from '@reduxjs/toolkit'
//
const todosAdapter = createEntityAdapter()
//
// { ids: [], entities: {} }
const initialState = todosAdapter.getInitialState()
//
const todosSlice = createSlice({
// ,
name: 'todos',
//
initialState,
//
reducers: {
// { type: 'todos/addTodo', payload: newTodo }
addTodo: todosAdapter.addOne,
// Redux Toolkit immer
toggleTodo(state, action) {
const { payload: id } = action
const todo = state.entities[id]
todo.completed = !todo.completed
},
updateTodo(state, action) {
const { id, text } = action.payload
const todo = state.entities[id]
todo.text = text
},
deleteTodo: todosAdapter.removeOne
}
})
// entities
export const { selectAll: selectAllTodos } = todosAdapter.getSelectors(
(state) => state.todos
)
//
export const {
addTodo,
toggleTodo,
updateTodo,
deleteTodo
} = todosSlice.actions
//
export default todosSlice.reducer
In the component, useDispatch () is used to access the dispatcher, and the activity creator imported from todosSlice.js is used to dispatch a specific operation:
import { useDispatch } from 'react-redux'
import { addTodo } from '../slices/todosSlice'
//
const dispatch = useDispatch()
dispatch(addTodo(newTodo))
Let's expand the functionality of our tudushka a little, namely: add the ability to filter tasks, buttons to complete all tasks and delete completed tasks, as well as some useful statistics. Let's also implement getting a list of tasks from the server.
Let's start with the server.
We will use JSON Server as the "fake API" . Here's a cheat sheet for working with it . Install json-server and concurrently - a utility for executing two or more commands:
yarn add json-server concurrently # npm i json-server concurrently
We make changes to the "scripts" section of package.json:
"server": "concurrently \"json-server -w db.json -p 5000 -d 1000\" \"yarn start\""
- -w - means monitoring changes to the "db.json" file
- -p - means port, by default requests from the application are sent to port 3000
- -d - delay response from the server
Create a file "db.json" in the root directory of the project (state-management):
{
"todos": [
{
"id": "1",
"text": "Eat",
"completed": true,
"visible": true
},
{
"id": "2",
"text": "Code",
"completed": true,
"visible": true
},
{
"id": "3",
"text": "Sleep",
"completed": false,
"visible": true
},
{
"id": "4",
"text": "Repeat",
"completed": false,
"visible": true
}
]
}
By default, all requests from the application are sent to port 3000 (the port on which the development server is running). In order for requests to be sent to port 5000 (the port on which the json-server will run), they must be proxied. Add the following line to package.json:
"proxy": "http://localhost:5000"
We start the server using the "yarn server" command.
We create another part of the state. slices / filterSlice.js:
import { createSlice } from '@reduxjs/toolkit'
//
export const Filters = {
All: 'all',
Active: 'active',
Completed: 'completed'
}
// -
const initialState = {
status: Filters.All
}
//
const filterSlice = createSlice({
name: 'filter',
initialState,
reducers: {
setFilter(state, action) {
state.status = action.payload
}
}
})
export const { setFilter } = filterSlice.actions
export default filterSlice.reducer
We make changes to store.js:
// preloadedState
import { configureStore } from '@reduxjs/toolkit'
import todosReducer from './modules/slices/todosSlice'
import filterReducer from './modules/slices/filterSlice'
const store = configureStore({
reducer: {
todos: todosReducer,
filter: filterReducer
}
})
export default store
Making changes to todosSlice.js:
import {
createSlice,
createEntityAdapter,
//
createSelector,
//
createAsyncThunk
} from '@reduxjs/toolkit'
// HTTP-
import axios from 'axios'
//
import { Filters } from './filterSlice'
const todosAdapter = createEntityAdapter()
const initialState = todosAdapter.getInitialState({
//
status: 'idle'
})
//
const SERVER_URL = 'http://localhost:5000/todos'
//
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
try {
const response = await axios(SERVER_URL)
return response.data
} catch (err) {
console.error(err.toJSON())
}
})
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
addTodo: todosAdapter.addOne,
toggleTodo(state, action) {
const { payload: id } = action
const todo = state.entities[id]
todo.completed = !todo.completed
},
updateTodo(state, action) {
const { id, text } = action.payload
const todo = state.entities[id]
todo.text = text
},
deleteTodo: todosAdapter.removeOne,
//
completeAllTodos(state) {
Object.values(state.entities).forEach((todo) => {
todo.completed = true
})
},
//
clearCompletedTodos(state) {
const completedIds = Object.values(state.entities)
.filter((todo) => todo.completed)
.map((todo) => todo.id)
todosAdapter.removeMany(state, completedIds)
}
},
//
extraReducers: (builder) => {
builder
//
// loading
// App.js
.addCase(fetchTodos.pending, (state) => {
state.status = 'loading'
})
//
//
//
.addCase(fetchTodos.fulfilled, (state, action) => {
todosAdapter.setAll(state, action.payload)
state.status = 'idle'
})
}
})
export const { selectAll: selectAllTodos } = todosAdapter.getSelectors(
(state) => state.todos
)
//
export const selectFilteredTodos = createSelector(
selectAllTodos,
(state) => state.filter,
(todos, filter) => {
const { status } = filter
if (status === Filters.All) return todos
return status === Filters.Active
? todos.filter((todo) => !todo.completed)
: todos.filter((todo) => todo.completed)
}
)
export const {
addTodo,
toggleTodo,
updateTodo,
deleteTodo,
completeAllTodos,
clearCompletedTodos
} = todosSlice.actions
export default todosSlice.reducer
We make changes to src / index.js:
// "App"
import { fetchTodos } from './redux-toolkit/modules/slices/todosSlice'
store.dispatch(fetchTodos())
App.js looks like this:
//
import { useSelector } from 'react-redux'
// -
import Loader from 'react-loader-spinner'
//
import {
TodoForm,
TodoList,
TodoFilters,
TodoControls,
TodoStats
} from './modules/components'
//
import { Container } from 'react-bootstrap'
// entitites
import { selectAllTodos } from './modules/slices/todosSlice'
export default function App() {
//
const { length } = useSelector(selectAllTodos)
//
const loadingStatus = useSelector((state) => state.todos.status)
//
const loaderStyles = {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}
if (loadingStatus === 'loading')
return (
<Loader
type='Oval'
color='#00bfff'
height={80}
width={80}
style={loaderStyles}
/>
)
return (
<Container style={{ maxWidth: '480px' }} className='text-center'>
<h1 className='mt-2'>Redux Toolkit</h1>
<TodoForm />
{length ? (
<>
<TodoStats />
<TodoFilters />
<TodoList />
<TodoControls />
</>
) : null}
</Container>
)
}
Other components code
TodoControls.js:
TodoFilters.js:
TodoForm.js:
TodoList.js:
TodoListItem.js:
TodoStats.js:
// redux
import { useDispatch } from 'react-redux'
// styles
import { Container, ButtonGroup, Button } from 'react-bootstrap'
// action creators
import { completeAllTodos, clearCompletedTodos } from '../slices/todosSlice'
export const TodoControls = () => {
const dispatch = useDispatch()
return (
<Container className='mt-2'>
<h4>Controls</h4>
<ButtonGroup>
<Button
variant='outline-secondary'
onClick={() => dispatch(completeAllTodos())}
>
Complete all
</Button>
<Button
variant='outline-secondary'
onClick={() => dispatch(clearCompletedTodos())}
>
Clear completed
</Button>
</ButtonGroup>
</Container>
)
}
TodoFilters.js:
// redux
import { useDispatch, useSelector } from 'react-redux'
// styles
import { Container, Form } from 'react-bootstrap'
// filters & action creator
import { Filters, setFilter } from '../slices/filterSlice'
export const TodoFilters = () => {
const dispatch = useDispatch()
const { status } = useSelector((state) => state.filter)
const changeFilter = (filter) => {
dispatch(setFilter(filter))
}
return (
<Container className='mt-2'>
<h4>Filters</h4>
{Object.keys(Filters).map((key) => {
const value = Filters[key]
const checked = value === status
return (
<Form.Check
key={value}
inline
label={value.toUpperCase()}
type='radio'
name='filter'
onChange={() => changeFilter(value)}
checked={checked}
/>
)
})}
</Container>
)
}
TodoForm.js:
// react
import { useState } from 'react'
// redux
import { useDispatch } from 'react-redux'
// libs
import { nanoid } from 'nanoid'
// styles
import { Container, Form, Button } from 'react-bootstrap'
// action creator
import { addTodo } from '../slices/todosSlice'
export const TodoForm = () => {
const dispatch = useDispatch()
const [text, setText] = useState('')
const updateText = ({ target: { value } }) => {
setText(value)
}
const handleAddTodo = (e) => {
e.preventDefault()
const trimmed = text.trim()
if (trimmed) {
const newTodo = { id: nanoid(5), text, completed: false }
dispatch(addTodo(newTodo))
setText('')
}
}
return (
<Container className='mt-4'>
<h4>Form</h4>
<Form className='d-flex' onSubmit={handleAddTodo}>
<Form.Control
type='text'
placeholder='Enter text...'
value={text}
onChange={updateText}
/>
<Button variant='primary' type='submit'>
Add
</Button>
</Form>
</Container>
)
}
TodoList.js:
// redux
import { useSelector } from 'react-redux'
// component
import { TodoListItem } from './TodoListItem'
// styles
import { Container, ListGroup } from 'react-bootstrap'
// selector
import { selectFilteredTodos } from '../slices/todosSlice'
export const TodoList = () => {
const filteredTodos = useSelector(selectFilteredTodos)
return (
<Container className='mt-2'>
<h4>List</h4>
<ListGroup>
{filteredTodos.map((todo) => (
<TodoListItem key={todo.id} todo={todo} />
))}
</ListGroup>
</Container>
)
}
TodoListItem.js:
// redux
import { useDispatch } from 'react-redux'
// styles
import { ListGroup, Form, Button } from 'react-bootstrap'
// action creators
import { toggleTodo, updateTodo, deleteTodo } from '../slices/todosSlice'
export const TodoListItem = ({ todo }) => {
const dispatch = useDispatch()
const { id, text, completed } = todo
const handleUpdateTodo = ({ target: { value } }) => {
const trimmed = value.trim()
if (trimmed) {
dispatch(updateTodo({ id, trimmed }))
}
}
const inputStyles = {
outline: 'none',
border: 'none',
background: 'none',
textAlign: 'center',
textDecoration: completed ? 'line-through' : '',
opacity: completed ? '0.8' : '1'
}
return (
<ListGroup.Item className='d-flex align-items-baseline'>
<Form.Check
type='checkbox'
checked={completed}
onChange={() => dispatch(toggleTodo(id))}
/>
<Form.Control
style={inputStyles}
defaultValue={text}
onChange={handleUpdateTodo}
disabled={completed}
/>
<Button variant='danger' onClick={() => dispatch(deleteTodo(id))}>
Delete
</Button>
</ListGroup.Item>
)
}
TodoStats.js:
// react
import { useState, useEffect } from 'react'
// redux
import { useSelector } from 'react-redux'
// styles
import { Container, ListGroup } from 'react-bootstrap'
// selector
import { selectAllTodos } from '../slices/todosSlice'
export const TodoStats = () => {
const allTodos = useSelector(selectAllTodos)
const [stats, setStats] = useState({
total: 0,
active: 0,
completed: 0,
percent: 0
})
useEffect(() => {
if (allTodos.length) {
const total = allTodos.length
const completed = allTodos.filter((todo) => todo.completed).length
const active = total - completed
const percent = total === 0 ? 0 : Math.round((active / total) * 100) + '%'
setStats({
total,
active,
completed,
percent
})
}
}, [allTodos])
return (
<Container className='mt-2'>
<h4>Stats</h4>
<ListGroup horizontal>
{Object.entries(stats).map(([[first, ...rest], count], index) => (
<ListGroup.Item key={index}>
{first.toUpperCase() + rest.join('')}: {count}
</ListGroup.Item>
))}
</ListGroup>
</Container>
)
}
As we can see, with the advent of the Redux Toolkit, using Redux to manage application state has become easier than using the combination useContext () + useReducer () (unbelievable, but true), except that Redux provides more options for such management. However, Redux is still designed for large, complex stateful applications. Is there any alternative for managing the state of small to medium sized applications other than useContext () / useReducer (). The answer is yes it does. This is Recoil .
Recoil
Recoil Guide
Recoil is a new tool for managing state in React applications. What does new mean? This means that some of its APIs are still under development and may change in the future. However, the opportunities that we will use to create the tudushka are stable.
Atoms and selectors are at the heart of Recoil. The atom is part of the state, and the selector is part of the derived state. Atoms are created using the "atom ()" function, and selectors are created using the "selector ()" function. To retrieve values ββfrom atoms and selectors, useRecoilState () (read and write), useRecoilValue () (read only), useSetRecoilState () (write only) hooks, and others. Components that use the Recoil state must be wrapped in RecoilRoot. Feels like Recoil is intermediate between useState () and Redux.
Create a "recoil" directory for the latest tudushka and install Recoil:
yarn add recoil
#
npm i recoil
Project structure:
|--recoil |--modules |--atoms |--filterAtom.js |--todosAtom.js |--components |--index.js |--TodoControls.js |--TodoFilters.js |--TodoForm.js |--TodoList.js |--TodoListItem.js |--TodoStats.js |--App.js
This is what the task list atom looks like:
// todosAtom.js
//
import { atom, selector } from 'recoil'
// HTTP-
import axios from 'axios'
//
const SERVER_URL = 'http://localhost:5000/todos'
//
export const todosState = atom({
key: 'todosState',
default: selector({
key: 'todosState/default',
get: async () => {
try {
const response = await axios(SERVER_URL)
return response.data
} catch (err) {
console.log(err.toJSON())
}
}
})
})
One of the interesting things about Recoil is that we can mix synchronous and asynchronous logic when creating atoms and selectors. It is designed in such a way that we have the ability to use React Suspense to render fallback content before receiving data. We also have the ability to use a fuse (ErrorBoundary) to catch errors that occur when creating atoms and selectors, including in an asynchronous way.
In this case, src / index.js looks like this:
import React, { Component, Suspense } from 'react'
import { render } from 'react-dom'
// recoil
import { RecoilRoot } from 'recoil'
//
import Loader from 'react-loader-spinner'
import App from './recoil/App'
// React
class ErrorBoundary extends Component {
constructor(props) {
super(props)
this.state = { error: null, errorInfo: null }
}
componentDidCatch(error, errorInfo) {
this.setState({
error: error,
errorInfo: errorInfo
})
}
render() {
if (this.state.errorInfo) {
return (
<div>
<h2>Something went wrong.</h2>
<details style={{ whiteSpace: 'pre-wrap' }}>
{this.state.error && this.state.error.toString()}
<br />
{this.state.errorInfo.componentStack}
</details>
</div>
)
}
return this.props.children
}
}
const loaderStyles = {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}
const root$ = document.getElementById('root')
// Suspense, ErrorBoundary
render(
<RecoilRoot>
<Suspense
fallback={
<Loader
type='Oval'
color='#00bfff'
height={80}
width={80}
style={loaderStyles}
/>
}
>
<ErrorBoundary>
<App />
</ErrorBoundary>
</Suspense>
</RecoilRoot>,
root$
)
The filter atom looks like this:
// filterAtom.js
// recoil
import { atom, selector } from 'recoil'
//
import { todosState } from './todosAtom'
export const Filters = {
All: 'all',
Active: 'active',
Completed: 'completed'
}
export const todoListFilterState = atom({
key: 'todoListFilterState',
default: Filters.All
})
// :
export const filteredTodosState = selector({
key: 'filteredTodosState',
get: ({ get }) => {
const filter = get(todoListFilterState)
const todos = get(todosState)
if (filter === Filters.All) return todos
return filter === Filters.Completed
? todos.filter((todo) => todo.completed)
: todos.filter((todo) => !todo.completed)
}
})
Components extract values ββfrom atoms and selectors using the above hooks. For example, the code for the "TodoListItem" component looks like this:
//
import { useRecoilState } from 'recoil'
//
import { ListGroup, Form, Button } from 'react-bootstrap'
//
import { todosState } from '../atoms/todosAtom'
export const TodoListItem = ({ todo }) => {
// - useState() Recoil
const [todos, setTodos] = useRecoilState(todosState)
const { id, text, completed } = todo
const toggleTodo = () => {
const newTodos = todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
setTodos(newTodos)
}
const updateTodo = ({ target: { value } }) => {
const trimmed = value.trim()
if (!trimmed) return
const newTodos = todos.map((todo) =>
todo.id === id ? { ...todo, text: value } : todo
)
setTodos(newTodos)
}
const deleteTodo = () => {
const newTodos = todos.filter((todo) => todo.id !== id)
setTodos(newTodos)
}
const inputStyles = {
outline: 'none',
border: 'none',
background: 'none',
textAlign: 'center',
textDecoration: completed ? 'line-through' : '',
opacity: completed ? '0.8' : '1'
}
return (
<ListGroup.Item className='d-flex align-items-baseline'>
<Form.Check type='checkbox' checked={completed} onChange={toggleTodo} />
<Form.Control
style={inputStyles}
defaultValue={text}
onChange={updateTodo}
disabled={completed}
/>
<Button variant='danger' onClick={deleteTodo}>
Delete
</Button>
</ListGroup.Item>
)
}
Other components code
TodoControls.js:
TodoFilters.js:
TodoForm.js:
TodoList.js:
TodoStats.js:
// recoil
import { useRecoilState } from 'recoil'
// styles
import { Container, ButtonGroup, Button } from 'react-bootstrap'
// atom
import { todosState } from '../atoms/todosAtom'
export const TodoControls = () => {
const [todos, setTodos] = useRecoilState(todosState)
const completeAllTodos = () => {
const newTodos = todos.map((todo) => (todo.completed = true))
setTodos(newTodos)
}
const clearCompletedTodos = () => {
const newTodos = todos.filter((todo) => !todo.completed)
setTodos(newTodos)
}
return (
<Container className='mt-2'>
<h4>Controls</h4>
<ButtonGroup>
<Button variant='outline-secondary' onClick={completeAllTodos}>
Complete all
</Button>
<Button variant='outline-secondary' onClick={clearCompletedTodos}>
Clear completed
</Button>
</ButtonGroup>
</Container>
)
}
TodoFilters.js:
// recoil
import { useRecoilState } from 'recoil'
// styles
import { Container, Form } from 'react-bootstrap'
// filters & atom
import { Filters, todoListFilterState } from '../atoms/filterAtom'
export const TodoFilters = () => {
const [filter, setFilter] = useRecoilState(todoListFilterState)
return (
<Container className='mt-2'>
<h4>Filters</h4>
{Object.keys(Filters).map((key) => {
const value = Filters[key]
const checked = value === filter
return (
<Form.Check
key={value}
inline
label={value.toUpperCase()}
type='radio'
name='filter'
onChange={() => setFilter(value)}
checked={checked}
/>
)
})}
</Container>
)
}
TodoForm.js:
// react
import { useState } from 'react'
// recoil
import { useSetRecoilState } from 'recoil'
// libs
import { nanoid } from 'nanoid'
// styles
import { Container, Form, Button } from 'react-bootstrap'
// atom
import { todosState } from '../atoms/todosAtom'
export const TodoForm = () => {
const [text, setText] = useState('')
const setTodos = useSetRecoilState(todosState)
const updateText = ({ target: { value } }) => {
setText(value)
}
const addTodo = (e) => {
e.preventDefault()
const trimmed = text.trim()
if (trimmed) {
const newTodo = { id: nanoid(5), text, completed: false }
setTodos((oldTodos) => oldTodos.concat(newTodo))
setText('')
}
}
return (
<Container className='mt-4'>
<h4>Form</h4>
<Form className='d-flex' onSubmit={addTodo}>
<Form.Control
type='text'
placeholder='Enter text...'
value={text}
onChange={updateText}
/>
<Button variant='primary' type='submit'>
Add
</Button>
</Form>
</Container>
)
}
TodoList.js:
// recoil
import { useRecoilValue } from 'recoil'
// components
import { TodoListItem } from './TodoListItem'
// styles
import { Container, ListGroup } from 'react-bootstrap'
// atom
import { filteredTodosState } from '../atoms/filterAtom'
export const TodoList = () => {
const filteredTodos = useRecoilValue(filteredTodosState)
return (
<Container className='mt-2'>
<h4>List</h4>
<ListGroup>
{filteredTodos.map((todo) => (
<TodoListItem key={todo.id} todo={todo} />
))}
</ListGroup>
</Container>
)
}
TodoStats.js:
// react
import { useState, useEffect } from 'react'
// recoil
import { useRecoilValue } from 'recoil'
// styles
import { Container, ListGroup } from 'react-bootstrap'
// atom
import { todosState } from '../atoms/todosAtom'
export const TodoStats = () => {
const todos = useRecoilValue(todosState)
const [stats, setStats] = useState({
total: 0,
active: 0,
completed: 0,
percent: 0
})
useEffect(() => {
if (todos.length) {
const total = todos.length
const completed = todos.filter((todo) => todo.completed).length
const active = total - completed
const percent = total === 0 ? 0 : Math.round((active / total) * 100) + '%'
setStats({
total,
active,
completed,
percent
})
}
}, [todos])
return (
<Container className='mt-2'>
<h4>Stats</h4>
<ListGroup horizontal>
{Object.entries(stats).map(([[first, ...rest], count], index) => (
<ListGroup.Item key={index}>
{first.toUpperCase() + rest.join('')}: {count}
</ListGroup.Item>
))}
</ListGroup>
</Container>
)
}
Conclusion
So, you and I have implemented a list of tasks using four different approaches to managing state. What conclusions can be drawn from all this?
I will express my opinion, it does not claim to be the ultimate truth. Of course, choosing the right state management tool depends on the application's tasks:
- To manage local state (the state of one or two components; assuming the two are closely related) use useState ()
- Use Recoil or useContext () / useReducer () to manage distributed state (the state of two or more autonomous components) or the state of small and medium applications
- Note that if you just need to pass values ββto deeply nested components, then useContext () is fine (useContext () itself is not a tool for managing state)
- Finally, to manage global state (the state of all or most components) or the state of a complex application, use the Redux Toolkit
MobX, which I've heard a lot of good things about, hasn't gotten around to yet.
Thank you for your attention and have a nice day.