Redux 中只有一个全局唯一 store 状态树, 且由 reducers 创建 store.
export default appStore = createStore(rootReducers, initState);
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;
}
By default, configureStore
from Redux Toolkit will:
applyMiddleware
with a default list of middlewares
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;
}
在 Redux 中 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)
);
Redux normalizing state shape:
Normalizing data:
table
in state.data table
should store individual items in an { key, value }
object:
"p1" : { id : "p1", author : "user1", comments : ["comment1", "comment2"] }
.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'],
},
};
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;
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', function prepare(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'
* }
* }
**/
:::tip RTK Pitfall 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,
});
必须保持无任何副作用: 不修改传入参数, 不调用副作用函数
(api/date.now()/math.random())
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) => {},
});
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',
});
createReducer
: builder.addCase
and builder.addMatcher
:
case reducer
(CaseReducer<State, Action>
) will execute first.ActionCreator
from RTK has method ActionCreator.match(action: Action)
,
can used to TypeScript type narrowing.// Simple matcher
function isNumberValueAction(
action: AnyAction
): action is PayloadAction<{ value: number }> {
return typeof action.payload.value === 'number';
}
import { createReducer } from '@reduxjs/toolkit';
const reducer = createReducer(0, builder => {
builder
.addCase('increment', state => state + 1)
.addMatcher(
action => action.startsWith('i'),
state => state * 5
)
.addMatcher(
action => action.endsWith('t'),
state => state + 2
);
});
console.log(reducer(0, { type: 'increment' }));
// Returns 7, as the 'increment' case and both matchers all ran in sequence:
// - case 'increment": 0 => 1
// - matcher starts with 'i': 1 => 5
// - matcher ends with 't': 5 => 7
createReducer
and createSlice
uses immer
to let you write reducers as if they were mutating the state directly.
In reality, the reducer receives a proxy state
that translates all mutations into equivalent copy operations.
:::danger Mutating State Case
Only write mutating logic in RTK createSlice
and createReducer
API.
:::
import { createAction, createReducer } from '@reduxjs/toolkit';
interface Todo {
text: string;
completed: boolean;
}
const addTodo = createAction<Todo>('todos/add');
const toggleTodo = createAction<number>('todos/toggle');
const todosReducer = createReducer([] as Todo[], builder => {
builder
.addCase(addTodo, (state, action) => {
// This push() operation gets translated into
// the same extended-array creation as in the previous example.
const todo = action.payload;
state.push(todo);
})
.addCase(toggleTodo, (state, action) => {
// The "mutating" version of this case reducer is
// much more direct than the explicitly pure one.
const index = action.payload;
const todo = state[index];
todo.completed = !todo.completed;
});
});
:::tip Reducer Pitfall Ensure that either mutate state argument or return a new state, but not both. :::
Following reducer would throw an exception if a toggleTodo action is passed:
import { createAction, createReducer } from '@reduxjs/toolkit';
interface Todo {
text: string;
completed: boolean;
}
const toggleTodo = createAction<number>('todos/toggle');
const todosReducer = createReducer([] as Todo[], builder => {
builder.addCase(toggleTodo, (state, action) => {
const index = action.payload;
const todo = state[index];
// This case reducer both mutates the passed-in state...
todo.completed = !todo.completed;
// And returns a new value.
// This will throw an exception.
// In this example, the easiest fix is to remove the `return` statement.
return [...state.slice(0, index), todo, ...state.slice(index + 1)];
});
});
Other pitfalls for State Proxy
in ImmerJS:
Draft
objects in Immer
are wrapped in Proxy
,
so you cannot use ==
or ===
to test equality:
original
instead: const index = original(list).indexOf(element)
.id
field instead.Slice API is standard approach for writing Redux logic.
Internally, it uses createAction
and createReducer
,
also use Immer
to write immutable updates.
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
interface CounterState {
value: number;
}
const initialState = { value: 0 } as CounterState;
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment(state) {
state.value++;
},
decrement(state) {
state.value--;
},
incrementByAmount(state, action: PayloadAction<number>) {
state.value += action.payload;
},
},
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
extraReducers
allows createSlice
to respond to
other action types besides the types it has generated.
If two fields from reducers and extraReducers happen to end up with the same action type string, the function from reducers will be used to handle that action type.
import type { Action, AnyAction } from '@reduxjs/toolkit';
import { createAction, createSlice } from '@reduxjs/toolkit';
interface RejectedAction extends Action {
error: Error;
}
interface Item {
id: string;
text: string;
}
// Counter actions
const incrementBy = createAction<number>('incrementBy');
const decrement = createAction('decrement');
function isRejectedAction(action: AnyAction): action is RejectedAction {
return action.type.endsWith('rejected');
}
const todosSlice = createSlice({
name: 'todo',
initialState: [] as Item[],
// Todo reducers
reducers: {
addTodo: {
reducer: (state, action: PayloadAction<Item>) => {
state.push(action.payload);
},
// Action creator prepare callback
prepare: (text: string) => {
const id = nanoid();
return { payload: { id, text } };
},
},
},
extraReducers: builder => {
builder
.addCase(incrementBy, (state, action) => {
// action is inferred correctly here if using TS
})
// You can chain calls, or have separate `builder.addCase()` lines each time
.addCase(decrement, (state, action) => {})
// You can match a range of action types
.addMatcher(
isRejectedAction,
// `action` will be inferred as a RejectedAction
(state, action) => {}
)
// and provide a default case if no other handlers matched
.addDefaultCase((state, action) => {});
},
});
The actual state is easier to read. Less logic is needed to calculate those additional values and keep them in sync with rest of data. The original state is still there as a reference and isn't being replaced.
Making change to data format in reducers,
then change reusable selector in slice.ts
.
No need to change Component.tsx
logic.
Keep useSelector
away from returns a new array reference:
// ❌ Bad: cause always re-render problem
function App() {
const postsForUser = useSelector(state => {
const allPosts = selectAllPosts(state);
// Returns a new array reference every time.
return allPosts.filter(post => post.user === userId);
});
}
useSelector
automatically subscribes to Redux store,
any time an action is dispatched,
it will call its selector function again right away.
If value returned by selector changes from last time it ran
(strict ===
reference comparisons),
useSelector
will force component to re-render with the new data.
createSelector
API
(Reselect under the hood):
Output Selector
will only re-run when outputs of Input Selector
have changed.
With createSelector
to write memorized selector functions:Input Selector
should usually just extract and return values,
Output Selector
should do expensive transformation work.// Good
const selectAllPosts = state => state.posts.posts;
const selectPostById = (state, postId) =>
state.posts.posts.find(post => post.id === postId);
// Memorized selector function
const selectPostsByUser = createSelector(
[selectAllPosts, (state, userId) => userId],
// Output selector will only re-run when `posts` or `userId` has changed.
(posts, userId) => posts.filter(post => post.user === userId)
);
Reselect
will run input selectors with all of given arguments,
If any of input selectors results are ===
different than before,
it will re-run output selector.
Otherwise it will skip re-running and just return cached final result from before.
const state1 = getState();
// Output selector runs, because it's the first call.
selectPostsByUser(state1, 'user1');
// Output selector does _not_ run, because the arguments haven't changed.
selectPostsByUser(state1, 'user1');
// Output selector runs, because `userId` changed.
selectPostsByUser(state1, 'user2');
dispatch(reactionAdded());
const state2 = getState();
// Output selector does not run, because `posts` and `userId` are the same.
selectPostsByUser(state2, 'user2');
// Add some more posts.
dispatch(addNewPost());
const state3 = getState();
// Output selector runs, because `posts` has changed.
selectPostsByUser(state3, 'user2');
// ❌ DO NOT memoize: will always return a consistent reference
const selectTodos = state => state.todos;
const selectNestedValue = state => state.some.deeply.nested.field;
const selectTodoById = (state, todoId) => state.todos[todoId];
// ❌ DO NOT memoize: deriving data, but will return a consistent result
const selectItemsTotal = state => {
return state.items.reduce((result, item) => {
return result + item.total;
}, 0);
};
const selectAllCompleted = state => state.todos.every(todo => todo.completed);
// ✅ SHOULD memoize: returns new references when called
const selectTodoDescriptions = state => state.todos.map(todo => todo.text);
Redux Toolkit configureStore
function automatically
sets up the thunk middleware by default,
recommend using thunks as the standard approach for writing async logic with Redux.
function createThunkMiddleware(extraArgument) {
return ({ dispatch, getState }) =>
next =>
action => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument);
}
return next(action);
};
}
const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;
export default thunk;
createAsyncThunk
API provides:
state.status
(idle | loading | error
) manipulation.AppThunk
type definition:
import type { Action, ThunkAction } from '@reduxjs/toolkit';
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
Action<string>
>;
Typed async thunk function:
interface SerializedError {
name?: string;
message?: string;
code?: string;
stack?: string;
}
interface PendingAction<ThunkArg> {
type: string;
payload: undefined;
meta: {
requestId: string;
arg: ThunkArg;
};
}
interface FulfilledAction<ThunkArg, PromiseResult> {
type: string;
payload: PromiseResult;
meta: {
requestId: string;
arg: ThunkArg;
};
}
interface RejectedAction<ThunkArg> {
type: string;
payload: undefined;
error: SerializedError | any;
meta: {
requestId: string;
arg: ThunkArg;
aborted: boolean;
condition: boolean;
};
}
interface RejectedWithValueAction<ThunkArg, RejectedValue> {
type: string;
payload: RejectedValue;
error: { message: 'Rejected' };
meta: {
requestId: string;
arg: ThunkArg;
aborted: boolean;
};
}
type Pending = <ThunkArg>(
requestId: string,
arg: ThunkArg
) => PendingAction<ThunkArg>;
type Fulfilled = <ThunkArg, PromiseResult>(
payload: PromiseResult,
requestId: string,
arg: ThunkArg
) => FulfilledAction<ThunkArg, PromiseResult>;
type Rejected = <ThunkArg>(
requestId: string,
arg: ThunkArg
) => RejectedAction<ThunkArg>;
type RejectedWithValue = <ThunkArg, RejectedValue>(
requestId: string,
arg: ThunkArg
) => RejectedWithValueAction<ThunkArg, RejectedValue>;
import { createAsyncThunk } from '@reduxjs/toolkit';
const fetchUserById = createAsyncThunk<
// Return type of the payload creator
ReturnType,
// First argument to the payload creator
number,
{
// Optional fields for defining thunkApi field types
dispatch: AppDispatch;
state: State;
extra: {
jwt: string;
};
}
>('users/fetchById', async (userId, thunkApi) => {
const response = await fetch(`https://reqres.in/api/users/${userId}`, {
headers: {
Authorization: `Bearer ${thunkApi.extra.jwt}`,
},
});
return (await response.json()) as ReturnType;
});
State status manipulation:
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { userAPI } from './userAPI';
// First, create the thunk.
const fetchUserById = createAsyncThunk(
'users/fetchByIdStatus',
async (userId, thunkAPI) => {
const response = await userAPI.fetchById(userId);
return response.data;
}
);
// Then, handle actions in your reducers:
const usersSlice = createSlice({
name: 'users',
initialState: { entities: [], loading: 'idle' },
reducers: {
// Standard reducer logic, with auto-generated action types per reducer.
},
extraReducers: builder => {
// Add reducers for additional action types and handle loading state as needed.
builder.addCase(fetchUserById.fulfilled, (state, action) => {
// Add user to the state array.
state.entities.push(action.payload);
});
},
});
// Later, dispatch the thunk as needed in the app.
dispatch(fetchUserById(123));
Redux middleware were designed to enable writing side effects logic:
reducer
function.dispatch
function.uuid()
/Math.random()
/Date.now()
).每一个 Middleware 可以通过上下文获取:
store
:
store.dispatch
.store.getState
.dispatch
对象直接发布 action
对象.next
方法: 前一个 Middleware 返回的 dispatch
方法.
当前 Middleware 可以根据自己对 action 的判断和处理结果,
决定是否调用 next
方法 (是否跳过其他 Middleware 的 dispatch
),
以及传入什么样的参数.从而实现如下功能:
dispatch
and getState
.dispatch
how to accept other values besides plain action objects,
such as functions (action(dispatch, getState, extraArgument)
) and promises,
by intercepting them and dispatching real action objects instead.store => next => action => T
.middleware(store)
: next => action => T
.middleware(store)(next)
: action => T
.next
: action => T
.dispatch
: action => T
.middleware(store)(next)
, next
and dispatch
have same function signature:
type Dispatch = (action: Action | AsyncAction) => any
.middlewares.forEach
, set next
to store.dispatch
,
make new dispatch
get all functions from middlewares
.function applyMiddleware(store, middlewares) {
middlewares = middlewares.slice();
middlewares.reverse();
let next = store.dispatch;
// Reduce middlewares with reverse order in Redux.
middlewares.forEach(middleware => (next = middleware(store)(next)));
// When user app execute `dispatch` function,
// middlewares execute with forward order.
return Object.assign({}, store, { dispatch: next });
}
import { applyMiddleware, combineReducers, createStore } from 'redux';
// applyMiddleware takes createStore() and returns
// a function with a compatible API.
const createStoreWithMiddleware = applyMiddleware(
logger,
crashReporter
)(createStore);
// Use it like you would use createStore()let todoApp = combineReducers(reducers);
const store = createStoreWithMiddleware(todoApp);
/**
* Schedules actions with { meta: { delay: N } } to be delayed by N milliseconds.
* Makes `dispatch` return a function to cancel the interval in this case.
*/
const timeoutScheduler = store => next => action => {
if (!action.meta || !action.meta.delay) {
return next(action);
}
const intervalId = setTimeout(() => next(action), action.meta.delay);
return function cancel() {
clearInterval(intervalId);
};
};
// thunk middleware
const thunk = store => next => action =>
typeof action === 'function'
? action(store.dispatch, store.getState)
: next(action);
const createStoreWithMiddleware = applyMiddleware(
logger,
thunk,
timeoutScheduler
)(createStore);
const store = createStoreWithMiddleware(combineReducers(reducers));
function addFave(tweetId) {
return (dispatch, getState) => {
if (getState.tweets[tweetId] && getState.tweets[tweetId].liked) {
return;
}
dispatch({ type: IS_LOADING });
// Yay, that could be sync or async dispatching
remote.addFave(tweetId).then(
res => {
dispatch({ type: ADD_FAVE_SUCCEED });
},
err => {
dispatch({ type: ADD_FAVE_FAILED, err });
}
);
};
}
store.dispatch(addFave());
export interface Middleware<
DispatchExt = {}, // optional override return behavior of `dispatch`
S = any, // type of the Redux store state
D extends Dispatch = Dispatch // type of the dispatch method
> {
ext: DispatchExt;
}
import type { Middleware } from 'redux';
import type { RootState } from '../store';
export const exampleMiddleware: Middleware<
{}, // Most middleware do not modify the dispatch return value
RootState
> = store => next => action => {
const state = store.getState(); // correctly typed as RootState
};
out of date
data in the background.// Import the RTK Query methods from the React-specific entry point.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
// Define our single API slice object.
export const apiSlice = createApi({
// The cache reducer expects to be added at `state.api`.
reducerPath: 'api',
// All of our requests will have URLs starting with '/fakeApi'.
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
tagTypes: ['Post'],
// The "endpoints" represent operations and requests for this server.
endpoints: builder => ({
getPost: builder.query({
query: postId => `/posts/${postId}`,
}),
// The `getPosts` endpoint is a "query" operation that returns data.
getPosts: builder.query({
// The URL for the request is '/fakeApi/posts'.
query: () => '/posts',
providesTags: ['Post'],
}),
addNewPost: builder.mutation({
query: initialPost => ({
url: '/posts',
method: 'POST',
// Include the entire post object as the body of the request
body: initialPost,
}),
invalidatesTags: ['Post'],
}),
}),
});
// Export the auto-generated hook for the `getPost` query endpoint
export const { useGetPostQuery, useGetPostsQuery, useAddNewPostMutation } =
apiSlice;
import { apiSlice } from '../features/api/apiSlice';
export default configureStore({
reducer: {
// ... Other reducers.
[apiSlice.reducerPath]: apiSlice.reducer,
},
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(apiSlice.middleware),
});
import React from 'react';
import { useGetPostsQuery } from '../api';
import { PostExcerpt, Spinner } from '../components';
export const PostsList = () => {
const {
data: posts = [],
isLoading,
isSuccess,
isError,
error,
refetch,
} = useGetPostsQuery();
const sortedPosts = useMemo(
() => posts.slice().sort((a, b) => b.date.localeCompare(a.date)),
[posts]
);
let content;
if (isLoading) {
content = <Spinner text="Loading..." />;
} else if (isSuccess) {
content = sortedPosts.map(post => (
<PostExcerpt key={post.id} post={post} />
));
} else if (isError) {
content = <div>{error.toString()}</div>;
}
return (
<section className="posts-list">
<h2>Posts</h2>
<button onClick={refetch}>Refetch Posts</button>
{content}
</section>
);
};
import React, { useState } from 'react';
import { useAddNewPostMutation } from '../api';
export const AddPostForm = () => {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [userId, setUserId] = useState('');
const [addNewPost, { isLoading }] = useAddNewPostMutation();
const canSave = [title, content, userId].every(Boolean) && !isLoading;
const onSavePostClicked = async () => {
if (canSave) {
try {
await addNewPost({ title, content, user: userId }).unwrap();
setTitle('');
setContent('');
setUserId('');
} catch (err) {
console.error('Failed to save the post: ', err);
}
}
};
};
RTK Query creates a cache key for each unique endpoint
+ argument
combination,
and stores the results for each cache key separately.
Use the same query hook multiple times,
pass it different query parameters,
and each result will be cached separately in Redux store
.
It iss important to note that the query parameter must be a single value
(a primitive value or an object containing multiple fields, same as with createAsyncThunk
).
RTK Query will do shallow stable comparison of fields,
and re-fetch the data if any of them have changed.
By default, unused data is removed from the cache after 60 seconds,
can be configured in root API slice definition
or overridden in individual endpoint definitions using keepUnusedDataFor
flag.
RTK query cache utils:
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
tagTypes: ['Post'],
endpoints: builder => ({
getPosts: builder.query({
query: () => '/posts',
providesTags: (result = [], error, arg) => [
'Post',
...result.map(({ id }) => ({ type: 'Post', id })),
],
}),
getPost: builder.query({
query: postId => `/posts/${postId}`,
providesTags: (result, error, arg) => [{ type: 'Post', id: arg }],
}),
addNewPost: builder.mutation({
query: initialPost => ({
url: '/posts',
method: 'POST',
body: initialPost,
}),
invalidatesTags: ['Post'],
}),
editPost: builder.mutation({
query: post => ({
url: `posts/${post.id}`,
method: 'PATCH',
body: post,
}),
invalidatesTags: (result, error, arg) => [{ type: 'Post', id: arg.id }],
}),
}),
});
PATCH /posts/:postId
from the editPost mutation.GET /posts/:postId
as the getPost query is refetched.GET /posts
as the getPosts query is refetched.import {
createEntityAdapter,
createSelector,
createSlice,
} from '@reduxjs/toolkit';
import { apiSlice } from '../api/apiSlice';
const emptyUsers = [];
export const selectUsersResult = apiSlice.endpoints.getUsers.select();
export const selectAllUsers = createSelector(
selectUsersResult,
usersResult => usersResult?.data ?? emptyUsers
);
export const selectUserById = createSelector(
selectAllUsers,
(state, userId) => userId,
(users, userId) => users.find(user => user.id === userId)
);
injectEndpoints()
:
mutates original API slice object
to add additional endpoint definitions
and then returns it.enhanceEndpoints()
:
merged together on a per-definition basis.apiSlice
and extendedApiSlice
are the same object.import { apiSlice } from '../api/apiSlice';
export const extendedApiSlice = apiSlice.injectEndpoints({
endpoints: builder => ({
getUsers: builder.query({
query: () => '/users',
}),
}),
});
export const { useGetUsersQuery } = extendedApiSlice;
export const selectUsersResult = extendedApiSlice.endpoints.getUsers.select();
import { apiSlice } from '../api/apiSlice';
const usersAdapter = createEntityAdapter();
const initialState = usersAdapter.getInitialState();
export const extendedApiSlice = apiSlice.injectEndpoints({
endpoints: builder => ({
getUsers: builder.query({
query: () => '/users',
transformResponse: responseData => {
return usersAdapter.setAll(initialState, responseData);
},
}),
}),
});
export const { useGetUsersQuery } = extendedApiSlice;
const selectUsersResult = extendedApiSlice.endpoints.getUsers.select();
const selectUsersData = createSelector(
selectUsersResult,
usersResult => usersResult.data
);
export const { selectAll: selectAllUsers, selectById: selectUserById } =
usersAdapter.getSelectors(state => selectUsersData(state) ?? initialState);
useSelector
.useDispatch
:
dispatch function reference will be stable
as long as same store instance is being passed to the <Provider>
.import type { TypedUseSelectorHook } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import type store from './store';
type AppDispatch = typeof store.dispatch;
type RootState = ReturnType<typeof store.getState>;
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
import { shallowEqual, useSelector } from 'react-redux';
export default function useShallowEqualSelector(selector) {
return useSelector(selector, shallowEqual);
}
import { bindActionCreators } from 'redux';
import { useDispatch } from 'react-redux';
import { useMemo } from 'react';
export default function useActions(actions) {
const dispatch = useDispatch();
return useMemo(() => {
if (Array.isArray(actions)) {
return actions.map(a => bindActionCreators(a, dispatch));
}
return bindActionCreators(actions, dispatch);
}, [actions, dispatch]);
}
import { batch } from 'react-redux';
function myThunk() {
return (dispatch, getState) => {
// Only result in one combined re-render, not two.
batch(() => {
dispatch(increment());
dispatch(increment());
});
};
}
client.jsx
:
import React from 'react';
import { hydrate } from 'react-dom';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import App from './containers/App';
import counterApp from './reducers';
const preloadedState = window.__PRELOADED_STATE__;
delete window.__PRELOADED_STATE__;
const store = createStore(counterApp, preloadedState);
hydrate(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
server.js
:
import path from 'path';
import Express from 'express';
import qs from 'qs';
import React from 'react';
import { renderToString } from 'react-dom/server';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import counterApp from './reducers';
import App from './containers/App';
const app = Express();
const port = 3000;
app.use('/static', Express.static('static'));
app.use(handleRender);
function handleRender(req, res) {
// `parseInt` to prevent XSS attack
const params = qs.parse(req.query);
const counter = parseInt(params.counter, 10) || 0;
const preloadedState = { counter };
const store = createStore(counterApp, preloadedState);
const html = renderToString(
<Provider store={store}>
<App />
</Provider>
);
const finalState = store.getState();
res.send(renderFullPage(html, finalState));
}
function renderFullPage(html, preloadedState) {
// https://redux.js.org/usage/server-rendering#security-considerations
// `replace(/</g, '\\u003c')` to prevent XSS attack
return `
<!doctype html>
<html>
<head>
<title>Redux Universal Example</title>
</head>
<body>
<div id="root">${html}</div>
<script>
// WARNING: security issues around embedding JSON in HTML:
// https://redux.js.org/usage/server-rendering#security-considerations
window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(
/</g,
'\\u003c'
)}
</script>
<script src="/static/bundle.js"></script>
</body>
</html>
`;
}
app.listen(port);
const applyMiddleware =
(...middlewares) =>
store => {
// should return (next) => (action) => { ... } function
if (middlewares.length === 0) {
return dispatch => dispatch;
}
if (middlewares.length === 1) {
return middlewares[0];
}
// [ (next) => (action) => {...}, ... ] array
// next: (action) => { ... } function
const boundMiddlewares = middlewares.map(middleware => middleware(store));
return boundMiddlewares.reduce((a, b) => next => a(b(next)));
};
const createStore = (reducer, middleware) => {
// closure for storing global state
let state;
const subscribers = [];
const coreDispatch = action => {
validateAction(action);
state = reducer(state, action);
subscribers.forEach(handler => handler());
};
const getState = () => state;
const store = {
dispatch: coreDispatch,
getState,
subscribe: handler => {
subscribers.push(handler);
// unsubscribe function
return () => {
const index = subscribers.indexOf(handler);
if (index > 0) {
subscribers.splice(index, 1);
}
};
},
};
if (middleware) {
// store default dispatch
const dispatch = action => store.dispatch(action);
// middleware = ({ dispatch, getState }) => (next) => (action) => { ... };
// middleware is a higher-order function (return (action) => { ... });
// dispatch, getState and coreDispatch are injected into middleware as arguments
store.dispatch = middleware({
dispatch,
getState,
})(coreDispatch);
}
coreDispatch({
type: INIT_REDUX,
});
return store;
};
const isValidKey = key => {
return ['type', 'payload', 'error', 'meta'].includes(key);
};
const validateAction = action => {
if (!action || typeof action !== 'object' || Array.isArray(action)) {
throw new Error('Action must be an object!');
}
if (typeof action.type === 'undefined') {
throw new TypeError('Action must have a type!');
}
if (!Object.keys(action).every(isValidKey)) {
throw new Error(
'Action only have `type`, `payload`, `error` or `meta` field!'
);
}
};
<Consumer>{store => (<WrapperComponent store={store}>)}</Consumer>
export const Provider = ({ store, children }) => {
const StoreContext = React.createContext(store);
return (
<StoreContext.Provider value={store}>
<StoreContext.Consumer>
{store => {
const childrenWithStore = React.Children.map(children, child =>
React.cloneElement(child, { store: store })
);
return <div>{childrenWithStore}</div>;
}}
</StoreContext.Consumer>
</StoreContext.Provider>
);
};
export const connect =
(mapStateToProps = () => ({}), mapDispatchToProps = () => ({})) =>
Component => {
class Connected extends React.Component {
onStoreOrPropsChange(props) {
const { store } = this.props;
const state = store.getState();
const stateProps = mapStateToProps(state, props);
const dispatchProps = mapDispatchToProps(store.dispatch, props);
this.setState({
...stateProps,
...dispatchProps,
});
}
componentWillMount() {
const { store } = this.props;
this.onStoreOrPropsChange(this.props);
this.unsubscribe = store.subscribe(() =>
this.onStoreOrPropsChange(this.props)
);
}
componentWillReceiveProps(nextProps) {
this.onStoreOrPropsChange(nextProps);
}
componentWillUnmount() {
this.unsubscribe();
}
render() {
return <Component {...this.props} {...this.state} />;
}
}
return Connected;
};
reducers
are called to produce the next
store state.mapStateToProps
/useSelectors
of mounted components are called.mapStateToProps
/useSelector
that returned a different reference
from the previous render,
the associated components are rendered
(re-rendering problem).React.memo
, useMemo
, useCallback
etc.createEntityAdapter
API:
Ids
array as minimal core data (other than whole Data[]
).Array.find()
).createSelector
API.Necessity for importing Redux (状态多, 变化快, 更新复杂):
Redux style guide:
useSelector
.// add new item to state array
// bad and does not work case "ADD":
state.push(newItem);
// Good case "ADD":
[...state, newItem];
// delete new item to state array
// bad and does not work case "DELETE":
state.splice(index, 1);
// Good case "DELETE":
state.slice(0, index).concat(state.slice(index + 1));
// update new item to state array
// First way case "EDIT":
state
.slice(0, index)
.concat([{ id: 'id', value: 'newValue' }])
.slice(index + 1);
// Second way case "EDIT":
state.map(item => {
if (item.id === 'id') {
return {
...item,
value: 'newValue',
};
} else {
return item;
}
});
// bad
const loadTodo = id => async (dispatch, getState) => {
// only fetch the todo if it isn't already loaded
if (!getState().todos.includes(id)) {
const todo = await fetch(`/todos/${id}`);
dispatch(addTodo(todo));
}
};
// good
const loadTodo = (id, todos) => async dispatch => {
// only fetch the todo if it isn't already loaded
if (!todos.includes(id)) {
const todo = await fetch(`/todos/${id}`);
dispatch(addTodo(todo));
}
};
const fluxStandardAction = {
type: 'ADD_TODO',
payload: {
text: 'Do something',
},
meta,
};
const fluxStandardAction = {
type: 'ADD_TODO',
payload: new Error('Error'),
error: true,
};
useState
for component state:
作为组件局部状态管理器来用.
对于只影响单个组件实例的状态,
应作为 Local State 交由 useState
管理,
而不是将其并入 Global Store.Jotai
/Recoil
:
Split state into different atoms.
Atoms can be imported for any specific component without single-entry point.
Each atom handling different app domain/context (reducer).