Redux Basic Notes
Redux Concepts
- 单一数据源: 整个应用程序的状态存储在单个对象树中 (容易跟踪/调试)
- 状态只读: 通过 dispatch(action) 间接更改状态, 不直接写入状态
- 纯函数更改状态: reducer(state, action) => newState
Store
Redux 中只有一个全局唯一 store 状态树, 且由 reducers 创建 store.
export default appStore = createStore(rootReducers, initState)
Create Store
import { applyMiddleware, createStore } from 'redux'
import { composeWithDevTools } from 'redux-devtools-extension'
import thunkMiddleware from 'redux-thunk'
import monitorReducersEnhancer from './enhancers/monitorReducers'
import loggerMiddleware from './middleware/logger'
import rootReducer from './reducers'
export default function configureStore(preloadedState) {
const middlewares = [loggerMiddleware, thunkMiddleware]
const middlewareEnhancer = applyMiddleware(...middlewares)
const enhancers = [middlewareEnhancer, monitorReducersEnhancer]
const composedEnhancers = composeWithDevTools(...enhancers)
const store = createStore(rootReducer, preloadedState, composedEnhancers)
if (process.env.NODE_ENV !== 'production' && module.hot)
module.hot.accept('./reducers', () => store.replaceReducer(rootReducer))
return store
}
Configure Store
By default, configureStore
from Redux Toolkit will:
- Call
applyMiddleware
with a default list of middlewares- Async thunk middleware.
- Immutability check middleware: throw error when detecting mutations in reducers during a dispatch.
- Serializability check middleware: throw error when deeply detecting non-serializable values in state tree (functions, promises, symbols, and other non-plain-data values).
- Call
composeWithDevTools
to set up the Redux DevTools Extension.
import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit'
import loggerMiddleware from './middleware/logger'
import rootReducer from './reducers'
export default function configureAppStore(preloadedState) {
const store = configureStore({
reducer: rootReducer,
middleware: [loggerMiddleware, ...getDefaultMiddleware()],
preloadedState,
})
if (process.env.NODE_ENV === 'development' && module.hot)
module.hot.accept('./reducers', () => store.replaceReducer(rootReducer))
return store
}
State
在 Redux 中 State 并不显式定义:
- 初态与变化态皆由 Reducers 定义并控制
- Actions 中保存着 action.type 外, 还保存着供 Reducers 进行有效状态变化的其他信息(可自定义)
- 调用 Dispatch 方法自动向 Store 传递一个 Action(因为只有一个全局 Store, 故无需额外指定 Store 参数), Store 遍历调用其中的 Reducers, 根据 switch 语句进行匹配 action 处理
- reducer 只保存最基本的 state, 可计算出的 state 放在 mapStateToProps(selector) 中直接计算后绑定至 props
- 将数据保存在 Redux 存储中, 并在组件内部保持 UI 相关状态
Persisted State
// localStorage.getItem('state')/localStorage.setItem('state', serializedState)
const persistedState = loadLocalStorageState()
const appStore = createStore(rootReducers, persistedState)
appStore.subscribe(
throttle(() => {
saveLocalStorageState({
todos: store.getState().todos,
})
}, 1000)
)
Normalized State
Redux normalizing state shape:
- Only have one copy of each particular piece of data in state (no duplication).
- Normalized data is kept in lookup table (key-value store), where item IDs are keys, items themselves are values.
- There may also be an array of all of the IDs for a particular item type.
Normalizing data:
- Each type of data gets its own
table
in state. - Each
data table
should store individual items in an{ key, value }
object:"p1" : { id : "p1", author : "user1", comments : ["comment1", "comment2"] }
. - Any references to individual items should be item ID.
- Arrays of IDs should be used to indicate ordering.
const state = {
users: {
ids: ['user1', 'user2', 'user3'],
entities: {
user1: { id: 'user1', firstName, lastName },
user2: { id: 'user2', firstName, lastName },
user3: { id: 'user3', firstName, lastName },
},
},
}
const userId = 'user2'
const userObject = state.users.entities[userId]
Normalize nesting data with Normalizr:
const data = {
entities: {
authors: { byId: {}, allIds: [] },
books: { byId: {}, allIds: [] },
authorBook: {
byId: {
1: {
id: 1,
authorId: 5,
bookId: 22,
},
2: {
id: 2,
authorId: 5,
bookId: 15,
},
3: {
id: 3,
authorId: 42,
bookId: 12,
},
},
allIds: [1, 2, 3],
},
},
}
const blogPosts = [
{
id: 'post1',
author: { username: 'user1', name: 'User 1' },
body: '......',
comments: [
{
id: 'comment1',
author: { username: 'user2', name: 'User 2' },
comment: '.....',
},
{
id: 'comment2',
author: { username: 'user3', name: 'User 3' },
comment: '.....',
},
],
},
{
id: 'post2',
author: { username: 'user2', name: 'User 2' },
body: '......',
comments: [
{
id: 'comment3',
author: { username: 'user3', name: 'User 3' },
comment: '.....',
},
{
id: 'comment4',
author: { username: 'user1', name: 'User 1' },
comment: '.....',
},
{
id: 'comment5',
author: { username: 'user3', name: 'User 3' },
comment: '.....',
},
],
},
// and repeat many times
]
const normalizedBlogPosts = {
posts: {
byId: {
post1: {
id: 'post1',
author: 'user1',
body: '......',
comments: ['comment1', 'comment2'],
},
post2: {
id: 'post2',
author: 'user2',
body: '......',
comments: ['comment3', 'comment4', 'comment5'],
},
},
allIds: ['post1', 'post2'],
},
comments: {
byId: {
comment1: {
id: 'comment1',
author: 'user2',
comment: '.....',
},
comment2: {
id: 'comment2',
author: 'user3',
comment: '.....',
},
comment3: {
id: 'comment3',
author: 'user3',
comment: '.....',
},
comment4: {
id: 'comment4',
author: 'user1',
comment: '.....',
},
comment5: {
id: 'comment5',
author: 'user3',
comment: '.....',
},
},
allIds: ['comment1', 'comment2', 'comment3', 'comment4', 'comment5'],
},
users: {
byId: {
user1: {
username: 'user1',
name: 'User 1',
},
user2: {
username: 'user2',
name: 'User 2',
},
user3: {
username: 'user3',
name: 'User 3',
},
},
allIds: ['user1', 'user2', 'user3'],
},
}
Entity Adapter Tool
- Build normalized state.
- Return normalized state CURD operation reducers.
- Get data selectors by
getSelectors
.
import {
createAsyncThunk,
createEntityAdapter,
createSlice,
} from '@reduxjs/toolkit'
import { client } from './api'
const postsAdapter = createEntityAdapter({
sortComparer: (a, b) => b.date.localeCompare(a.date),
})
// State = { ids: [], entities: {}, status: 'idle', error: null };
const initialState = postsAdapter.getInitialState({
status: 'idle',
error: null,
})
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
const response = await client.get('/fakeApi/posts')
return response.data
})
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
reactionAdded(state, action) {
const { postId, reaction } = action.payload
const existingPost = state.entities[postId]
if (existingPost)
existingPost.reactions[reaction]++
},
postUpdated(state, action) {
const { id, title, content } = action.payload
const existingPost = state.entities[id]
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
},
},
extraReducers(builder) {
builder
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded'
// Use the `upsertMany` reducer as a mutating update utility
postsAdapter.upsertMany(state, action.payload)
})
// Use the `addOne` reducer for the fulfilled case
.addCase(addNewPost.fulfilled, postsAdapter.addOne)
},
})
export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions
// Export the customized selectors for this adapter using `getSelectors`
export const {
selectAll: selectAllPosts,
selectById: selectPostById,
selectIds: selectPostIds,
// Pass in a selector that returns the posts slice of state
} = postsAdapter.getSelectors(state => state.posts)
export const selectPostsByUser = createSelector(
[selectAllPosts, (state, userId) => userId],
(posts, userId) => posts.filter(post => post.user === userId)
)
export default postsSlice.reducer
Action
Because of ActionCreator.toString()
override,
action creators returned by createAction()
can be used directly as keys for case reducers
passed to createReducer()
.
import { createAction } from '@reduxjs/toolkit'
const increment = createAction<number | undefined>('counter/increment')
let action = increment() // { type: 'counter/increment' }
action = increment(3) // returns { type: 'counter/increment', payload: 3 }
console.log(increment.toString())
console.log(`The action type is: ${increment}`)
// 'counter/increment'
// 'The action type is: counter/increment'
import { createAction, nanoid } from '@reduxjs/toolkit'
const addTodo = createAction('todos/add', (text: string) => {
return {
payload: {
text,
id: nanoid(),
createdAt: new Date().toISOString(),
},
}
})
console.log(addTodo('Write more docs'))
/**
* {
* type: 'todos/add',
* payload: {
* text: 'Write more docs',
* id: '4AJvwMsWeHCChcWYga3dj',
* createdAt: '2019-10-03T07:53:36.581Z'
* }
* }
*/
Strongly recommend to only use string action types.
Redux Toolkit rests on the assumption that you use string action types.
Specifically, some of its features rely on the fact that with strings,
toString()
method of createAction()
action creator returns matching action type.
This is not the case for non-string action types because toString()
will return the string-converted type value rather than the type itself.
const INCREMENT = Symbol('increment')
const increment = createAction(INCREMENT)
increment.toString()
// returns the string 'Symbol(increment)',
// not the INCREMENT symbol itself.
assert(increment.toString() === INCREMENT, false)
const counterReducer = createReducer(0, {
// The following case reducer will NOT trigger for
// increment() actions because `increment` will be
// interpreted as a string, rather than being evaluated
// to the INCREMENT symbol.
[increment]: (state, action) => state + action.payload,
// You would need to use the action type explicitly instead.
[INCREMENT]: (state, action) => state + action.payload,
})
Reducer
必须保持无任何副作用: 不修改传入参数, 不调用副作用函数
(api/date.now()/math.random())
Reducer Boilerplate
function createReducer(initialState, handlers) {
return function reducer(state = initialState, action) {
if (Object.prototype.hasOwnProperty.call(handlers, action.type))
return handlers[action.type](state, action)
else return state
}
}
const reducer = createReducer(initialState, {
reset: () => initialState,
increment: state => ({ count: state.count + 1 }),
decrement: state => ({ count: state.count + 1 }),
[ActionTypes.ADD_TODO]: (state, action) => {},
})
Reducer Enhancer
Implement reducer enhancer with higher order reducer
,
like Redux Undo:
function undoable(reducer) {
// Call the reducer with empty action to populate the initial state
const initialState = {
past: [],
present: reducer(undefined, {}),
future: [],
}
// Return a reducer that handles undo and redo
return function (state = initialState, action) {
const { past, present, future } = state
switch (action.type) {
case 'UNDO': {
const previous = past[past.length - 1]
const newPast = past.slice(0, past.length - 1)
return {
past: newPast,
present: previous,
future: [present, ...future],
}
}
case 'REDO': {
const next = future[0]
const newFuture = future.slice(1)
return {
past: [...past, present],
present: next,
future: newFuture,
}
}
default: {
// Delegate handling the action to the passed reducer
const newPresent = reducer(present, action)
if (present === newPresent)
return state
return {
past: [...past, present],
present: newPresent,
future: [],
}
}
}
}
}
// This is a reducer
import { createStore } from 'redux'
function todos(state = [], action) {
/* ... */
}
// This is also a reducer!
const undoableTodos = undoable(todos)
const store = createStore(undoableTodos)
store.dispatch({
type: 'ADD_TODO',
text: 'Use Redux',
})
store.dispatch({
type: 'ADD_TODO',
text: 'Implement Undo',
})
store.dispatch({
type: 'UNDO',
})