preface
I reread Redux’s document recently and, unsurprisingly, it’s just as obscure.
While the documentation isn’t very good, it does provide a lot of good code organization, a lot of useful tools and plug-ins, and a slow understanding of how such a simple state center can generate so many concepts and libraries.
The Redux documentation, in addition to some conceptual introductions, mainly contains
- How to use
redux
This library organizes redux code - How to use
redux-toolkit
To organize redux code more intelligently
Redux documentation is difficult to understand because it is not written in a linear way. Many times a concept or method pops up, and redux, React-Redux, and Redux-Toolkit are often used in a confusing manner.
This article presents the best way to write Redux through step-by-step code optimization. (Note: The range of best practices here is limited to redux documents, but there are many better practices that are not discussed here.)
Here is the final code for the article github.com/learn-redux…
Now let’s explore Redux
Requirement – Todo App
Let’s make a Todo list as our requirements, mainly involving the operation of adding, deleting, modifying and searching todo. Todo App is a good example of adding, deleting, modifying and searching multiple resources to complex pages.
App reference is as follows
First edition – Beggar’s Version of Todo app
The beggar version means that we only use Redux to run the Todo app in the local test. Reducer. Ts and Store. Ts first.
// reducer.ts
const initTodos: TTodo[] = [
{
id: '1'.text: 'smoking'.state: 'done'
},
{
id: '2'.text: 'drink'.state: 'todo'
},
{
id: '3'.text: 'permed hair'.state: 'todo'}]const initFilter: TFilter = 'all'
const initState = {
todos: initTodos,
filter: initFilter
}
const reducer = (state = initState , action: any) = > {
switch (action.type) {
case 'addTodo':
const newTodos = [...state.todos, action.payload]
return{... state,todos: newTodos}
case 'removeTodo':
const newTodos = state.todos.filter(todo= >todo.id ! == action.payload)return { ...state, todos : newTodos }
case 'toggleTodo':
const newTodos = state.todos.map(todo= >todo.id === action.payload ? {... todo,state: todo.state === 'todo' ? 'done' : 'todo'}
: todo
)
return { ...state, todos: newTodos }
case 'setFilter':
return { ...state filter: action.payload }
case 'reset':
return initState
default:
return state
}
}
export default reducer
Copy the code
// store.ts
import {createStore} from "redux"
import reducer from "./reducer"
const store = createStore(reducer)
store.subscribe(() = > console.log('update component'))
export default store
Copy the code
Test code. For space reasons, only one use case is shown here.
// app.test.ts
it('Can add a Todo'.() = > {
const newTodo: TTodo = {
id: '99'.text: 'Eat good food'.state: 'todo',
}
store.dispatch({type: 'addTodo'.payload: newTodo})
const todos = store.getState().todos
expect(todos[todos.length - 1]).toEqual(newTodo)
})
Copy the code
The test will normally show that the last todo is “eat delicious”.
The store here is mainly a todo list and filter, and the code is very simple: add Todo, delete todo, toggle todo and reset some basic operations.
Second edition: use combineReducers to make slice
Note here that the redcuer actually contains operations on todos and filter. The whole reducer looks very long, so we will try to manage the Todos only by todosReducer. Filters are managed using filterReducer, which is called “slice”.
The reducer code above can be rewritten as:
const todosReducer = (todos: TTodo[] = initTodos, action: any) = > {
switch (action.type) {
case 'addTodo':
return [...todos, action.payload]
case 'removeTodo':
return todos.filter(todo= >todo.id ! == action.payload)case 'toggleTodo':
return todos.map(todo= >todo.id === action.payload ? {... todo,state: todo.state === 'todo' ? 'done' : 'todo'}
: todo
)
case 'reset':
return initTodos
default:
return todos
}
}
const filterReducer = (filter: TFilter = initFilter, action: any) = > {
switch (action.type) {
case 'setFilter':
return action.payload
case 'reset':
return initFilter
default:
return filter
}
}
const reducer = (state = initState, action: any) = > ({
todos: todosReducer(state, action),
filter: filterReducer(state, action)
})
Copy the code
Redux provides an API called combineReducers, where proxies can be organized like this:
const reducer = combineReducers({
todos: todosReducer,
filter: filterReducer
})
Copy the code
The effect is the same, but the code looks a little nicer.
React + Redux
Redux has nothing to do with React. It’s the React-Redux library that really makes it work.
$ yarn add react-redux
Copy the code
When I first learned Redux, I never knew the existence of redux and assumed that Redux was react state management just like VUex. In fact, React-Redux is.
read
The Provider component is used here to inject store objects globally, making the store accessible to everyone.
// ReactReduxTodo
const ReactReduxTodo: FC = () = > {
return (
<Provider store={store}>
<TodoApp />
</Provider>)}Copy the code
Read data from components can be retrieved using useSelector.
// TodoApp.tsx
const TodoApp: FC = () => {
const todos = useSelector<TStore, TTodo[]>(state => {
const todos = state.todos
if (state.filter === 'all') {
return todos
}
return todos.filter(todo => todo.state === state.filter)
}
)
...
}
Copy the code
The first argument to useSelector is a function that returns the desired state data. At this point we find that the function passed in is too long to look good in useSelector, and we have to write it again if other components also want todos, so we can extract the function and look like this:
// selectors.ts
export const selectFilteredTodos = (state: TStore): TTodo[] => {
const todos = Object.values(state.todos.entities)
if (state.filter === 'all') {
return todos
}
return todos.filter(todo= > todo.state === state.filter)
}
// TodoApp.tsx
const TodoApp: FC = () = > {
const todos = useSelector<TStore, TTodo[]>(selectFilteredTodos)
...
}
Copy the code
This extracted function is called selector, which is also hooksuseSelector
The origin of the name.
Write the data
Write numbers are mainly dispatch actions. You can use useDispatch to obtain the Dispatch function.
const TodoApp: FC = () = > {
const dispatch = useDispatch()
const onAddTodo = (text) = > {
dispatch({
type: 'addTodo'.payload: {
id: new Date().toISOString(),
text,
state: 'todo'
}
})
setTask(' ')}... }Copy the code
We found that ‘addTodo’ here is hard coded and not a good habit, so we need to create a variable to store it. These variables that describe the action type are usually put in actiontypes.ts
// actionTypes.ts
export const ADD_TODO = 'addTodo'
// TodoApp.tsx
const TodoApp: FC = () = > {
const dispatch = useDispatch()
const onAddTodo = (text) = > {
dispatch({
type: ADD_TODO,
payload: {
id: new Date().toISOString(),
text,
state: 'todo'
}
})
setTask(' ')}... }Copy the code
In addition, the Redux documentation does not recommend that we write the action directly in the component. Instead, we should use a function to generate the action. This function is called Action Creator
// actionTypes.ts
export const ADD_TODO = 'addTodo'
// actionCreators.ts
export const addTodo = (text: string) = > ({
type: ADD_TODO,
payload: {
id: new Date().toISOString(),
text,
state: 'todo'}})})// TodoApp.tsx
const TodoApp: FC = () = > {
const dispatch = useDispatch(addTodo(text))
const onAddTodo = (text) = > {
dispatch({
type: ADD_TODO,
payload:
setTask(' ')}... }Copy the code
Let’s look at our Reducer, all we need to change here is to remove the hard coding
// reducer.ts
const todosReducer = (todoState: TTodo = initTodos, action: any) = > {
switch (action.type) {
case ADD_TODO:
return[...todoState, action.payload] ... }}Copy the code
Fourth edition: Classification
So far we have unknowingly added actionCreators. Ts, actionTypes. Ts and selectors. But these three files contain action Creator, Action Type, and Selector for both Todos and Filters.
At this time, we need to add loading slice to the page, and there are many loading slice items in each file. Therefore, it is better to classify the loading slice according to the above mentioned slice, so we can have the following directory structure:
At the same time, we need to go to Comebine reducer in Store. ts
import {combineReducers, createStore} from "redux"
import todosReducer from "./todos/reducer"
import filterReducer from "./filter/reducer"
import loadingReducer from "./loading/reducer"
const reducer = combineReducers({
todos: todosReducer,
filter: filterReducer,
loading: loadingReducer
})
const store = createStore(reducer)
export default store
Copy the code
Doesn’t that make you feel a lot cleaner? Each slice acts as a small store and does not interfere with each other.
Version 5: Table Drive Optimization reducer
When the number of operations increases, we find that the action types also change, and the reducer structure becomes ugly:
// todos/reducer.ts
const todosReducer = (todos: TTodo[] = initTodos, action: any) = > {
switch (action.type) {
case SET_DODOS:
return [...action.payload]
case ADD_TODO:
return [...todos, action.payload]
case REMOVE_TODO:
return todos.filter(todo= >todo.id ! == action.payload)case TOGGLE_TODO:
return todos.map(todo= >todo.id === action.payload ? {... todo,state: todo.state === 'todo' ? 'done' : 'todo'}
: todo
)
default:
return todos
}
}
Copy the code
All switch-cases can be optimized in a table-driven manner, as can be done here:
// todos/reducer.ts
const todosReducer = (todos: TTodo[] = initTodos, action: any) = > {
const handlerMapper = {
[SET_TODOS]: (todos, action) = > {
return [...action.payload]
},
[ADD_TODO]: (todos, action) = > {
return [...todos, action.payload]
},
[REMOVE_TODO]: (todos, action) = > {
return todos.filter(todo= >todo.id ! == action.payload) }, [TOGGLE_TODO]:(todos, action) = > {
return todos.map(todo= >todo.id === action.payload ? {... todo,state: todo.state === 'todo' ? 'done' : 'todo'}
: todo
)
}
}
const handler = handlerMapper[action.type]
return handler ? handler(todos, action) : todos
}
Copy the code
This is how to use table drive. However, if you write this in TypeScript, you will definitely get an error, mainly because you haven’t defined the handlerMapper type, and you haven’t defined the action type. So we also have to do the type definition.
// todos/actionTypes.ts
export const ADD_TODO = 'addTodo'
export type ADD_TODO = typeof ADD_TODO
...
// todos/actionCreators.ts
export type TAddTodoAction = {
type: ADD_TODO; payload: TTodo; }...export type TTodoAction = TAddTodoAction | TToggleTodoAction...
// todos/reducer.ts
type THandler = (todoState: TTodoStore, action: TTodoAction) = > TTodoStore
type THandlerMapper = {[key: string]: THandler}
const todosReducer = (todos: TTodo[] = initTodos, action: any) = > {
const handlerMapper: THandlerMapper = {
...
}
const handler = handlerMapper[action.type]
return handler ? handler(todos, action) : todos
}
Copy the code
Version 6: Optimize reducer using IMmer
Now looking at the todosReducer, we see that the immutable array is returned using the extended operator each time state is returned, which is inevitable if state is an object
return { ... prevState ... Return object. assign({}, prevState, newState)Copy the code
If state were an array, it would look like this
return [...prevState, newItem]
Copy the code
One is fine, it would be gross if every handler had to do that. Redux recommends using the immer library for immutable. The installation is as follows:
$ yarn add immer
Copy the code
The library eliminates the need for extension operators to create new objects and arrays. Instead, it makes it possible to construct new objects and arrays by using the mutable method. Reducer as above can be written into
import produce from 'immer' // todos/reducer.ts const todosReducer = (todos: TTodo[] = initTodos, action: any) => { const handlerMapper = { [SET_TODOS]: (todos, action) => { return [...action.payload] }, [ADD_TODO]: (todos, action) => { return produce(todos, draftTodos => { draftTodos.push(action.payload) )} }, [REMOVE_TODO]: (todos, action) => { return todos.filter(todo => todo.id ! == action.payload) }, [TOGGLE_TODO]: (todos, action) => { return produce(todos, draftTodos => { const draftTodo = draftTodos.find(t => t.id === action.payload) draftTodo.state = draftTodo.state === 'todo' ? 'done' : 'todo' }) } } const handler = handlerMapper[action.type] return handler ? handler(todos, action) : todos }Copy the code
With immer, array push and direct assignment can be used directly, and the code feels better.
Version 7: Normalize data to optimize the todosStore
From the reducer modification above, we found a problem with TOGGLE_TODO. Because the input parameter must be an ID, we must drafttodos.find () every time toggle and then change the value. While there’s not much data here, this is not a particularly good habit, and it’s best to grab draftTodo directly with O(1).
Hash table (1); hash table (1); hash table (1);
todosStore = {
ids: ['1'.'2'. ]entities: {
1: {
id: '1'.text: 'smoking'.state: 'done'},... }}Copy the code
** Changes the array to {ids:… , entities: … The process of Normalization is called Normalization. This change was quite an effort because all the logic had to be changed and the types had to be changed. Ahh, ahh, so annoying. It will look like this:
// todos/reducer.ts
const todosReducer = (todoState: TTodoStore = initTodos, action: any) = > {
const handlerMapper: THandlerMapper = {
[SET_TODOS]: (todoState, action) = > {
const {payload: todos} = action as TSetTodosAction
const entities = produce<TTodoEntities>({}, draft= > {
todos.forEach(t= > {
draft[t.id] = t
})
})
return {
ids: todos.map(t= > t.id),
entities
}
},
[UPDATE_TODO]: (todoState, action) = > {
return produce(todoState, draft= > {
const {payload: {id, text}} = action as TUpdateTodoAction
draft.entities[id].text = text
})
},
[TOGGLE_TODO]: (todoState, action) = > {
return produce(todoState, draft= > {
const {payload: id} = action as TToggleTodoAction
const todo = draft.entities[id]
todo.state = todo.state === 'todo' ? 'done' : 'todo'})},... }const handler = handlerMapper[action.type]
return handler ? handler(todoState, action) : todoState
}
Copy the code
In fact, after the change will become very cool, direct access to the real fragrance.
Version 8: Use Thunk to handle asynchrony
This is all about data-level operations, not asynchronous processing. Redux does not recommend sending request code in the Reducer. This code should be in Action Creator. But Action Creator doesn’t do anything but return the Action object, so you need the redux-thunk library.
Redux-thubk is middleware and is simple to use
// store.ts
import {applyMiddleware, createStore} from "redux"
import ReduxThunk from 'redux-thunk'.const store = createStore(reducer, applyMiddleware(ReduxThunk))
Copy the code
Then you can have fun with it. In this case, you just need to return Action Creator to a function that contains asynchronous logic with parameters dispatch and getState.
// todos/actionCreators. Ts -> Action Creator returns function for asynchronous code
export const addTodo = (newTodo: TTodo) = > async (dispatch: Dispatch) => {
dispatch(setLoading({status: true.tip: 'Adding... '}))
const response: TTodo = await fetch('/addTodo', {data: newTodo})
dispatch({ type: ADD_TODO, payload: response })
dispatch(setLoading({status: false.tip: ' '}}))/ / loading/actionCreators. Ts - > ordinary action creator to return to action
export const setLoading = (loading: TLoading) = > ({
type: 'setLoading'.payload: loading
})
// TodoApp.tsx
const onAddTodo = () = > {
dispatch(addTodo(newTodo))
}
Copy the code
Version 9: Use React. Memo + useCallback to improve performance
In TodoApp we might need to show TodoList, maybe it’ll say something like this
// TodoApp.tsx
const TodoApp: FC = () = > {
const dispatch = useDispatch()
...
const onToggleTodo = (id: string) = > {
dispatch(toggleTodo(id))
}
return( <div className="app"> <List> { todos.map(todo => <TodoItem todo={todo} onToggle={onToggleTodo} />) } <List> </div> ) } // TodoItem.tsx const TodoItem: FC<IProps> = (props) => { const {todo, onToggle} = props console.log('fuck') return ( <li> {todo.text} <button onClick={() => onToggle(todo.id)}>Toggle</button> </li> ) }Copy the code
If you have three toDos and you toggle one of them, you’ll see three ‘fuck’ typed. And that’s because we’re using useSelector in TodoApp, and every time our selectFilteredTodos selector returns a new array, TodoApp rerenders, the parent gets rendered, the child gets rerendered, so it gets rendered three times, This is clearly not the way to do it.
To render only the changed child component and leave everything else untouched, we need to use the react. memo as follows:
// TodoItem.tsx
const TodoItem: FC<IProps> = (props) => {
const {todo, onToggle} = props
console.log('fuck')
return (
<li>
{todo.text}
<button onClick={() => onToggle(todo.id)}>Toggle</button>
</li>
)
}
export default React.memo(TodoItem)
Copy the code
The react. memo is passed to the component. If the props of the component are the same, then there is no need to re-render. We know that the todo object should be changed to a new TOdo object if the state is changed, otherwise the original todo object should still be used, so rendering should not be triggered.
But it’s easy to ignore onToggle, because the reference to this function changes every time, so we’ll use useCallback to cache the reference to this function:
const onToggleTodo = useCallback((id: string) = > {
dispatch(toggleTodo(id))
}, [dispatch])
Copy the code
In this case, we are listening on the Dispatch, because the dispatch usually doesn’t change, so we can cache the onToggleTodo function.
We toggle todo again and only one ‘fuck’ comes up.
Version 10: Add dev Tools
Redux Dev Tools is a Chrome plugin that makes it easy to keep track of every store change.
Chrome plugin store installation address
Making the address
After installing the plugin, you just need to configure it in store.ts:
import {applyMiddleware, combineReducers, createStore} from "redux"
import {composeWithDevTools} from 'redux-devtools-extension'.const enhancer = process.env.NODE_ENV === 'development' ? composeWithDevTools(
applyMiddleware(ReduxThunk)
) :applyMiddleware(ReduxThunk)
const store = createStore(reducer, enhancer)
export default store
Copy the code
Click redux in developer tools to see what the Store looks like:
conclusion
As you can see, Redux is a very simple concept: how to manage global variables (state).
As you can see from the example above, the REdux API is only used
- createStore
- combineReducers
- applyMiddleware
The React-Redux API is only used
- Dojo.provide components
- useSelector
- useDispatch
Those reducer, action creator, action type, selector, etc., although there are many concepts, but are not API level, just changed a statement.
You can see that the final version above feels ok, but is not smart enough. For example, why normalize the data myself? Why write your own table driver? Why do I have to optimize with React. Memo and useCallback myself? Why do I have to install redux-Thunk and immer myself? Redux you already provide comebineReducers why not provide more apis to do these things?
Redux is a headache for many people because there is so much code to write using Redux to manage state, such as selecor + actionCreator + actionType + Reducer + slice. Therefore, in order to make it easier to write these “template code”, there are many libraries of Redux. Redux-toolkit is also released by Redux to help developers organize their code.
The next article will talk about how to change all of the above code to the recommended redux-Toolkit. It will be a great process. See you in the next article
(after)