Skip to content

Commit 6fcaab4

Browse files
authored
Merge pull request #4665 from reduxjs/combineslices-docs
2 parents bd0250d + 1d687e0 commit 6fcaab4

File tree

2 files changed

+382
-2
lines changed

2 files changed

+382
-2
lines changed

docs/usage/CodeSplitting.md

Lines changed: 379 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ store.replaceReducer(newRootReducer)
2626

2727
## Reducer Injection Approaches
2828

29+
This section will cover some handwritten recipes used to inject reducers.
30+
2931
### Defining an `injectReducer` function
3032

3133
We will likely want to call `store.replaceReducer()` from anywhere in the application. Because of that, it's helpful
@@ -154,8 +156,383 @@ To add a new reducer, one can now call `store.reducerManager.add("asyncState", a
154156

155157
To remove a reducer, one can now call `store.reducerManager.remove("asyncState")`
156158

157-
## Libraries and Frameworks
159+
## Redux Toolkit
160+
161+
Redux Toolkit 2.0 includes some utilities designed to simplify code splitting with reducers and middleware, including solid Typescript support (a common challenge with lazy loaded reducers and middleware).
162+
163+
### `combineSlices`
164+
165+
The [`combineSlices`](https://redux-toolkit.js.org/api/combineSlices) utility is designed to allow for easy reducer injection. It also supercedes `combineReducers`, in that it can be used to combine multiple slices and reducers into one root reducer.
166+
167+
At setup it accepts a set of slices and reducer maps, and returns a reducer instance with attached methods for injection.
168+
169+
:::note
170+
171+
A "slice" for `combineSlices` is typically created with `createSlice`, but can be any "slice-like" object with `reducerPath` and `reducer` properties (meaning RTK Query API instances are also compatible).
172+
173+
```ts
174+
const withUserReducer = rootReducer.inject({
175+
reducerPath: 'user',
176+
reducer: userReducer
177+
})
178+
179+
const withApiReducer = rootReducer.inject(fooApi)
180+
```
181+
182+
For simplicity, this `{ reducerPath, reducer }` shape will be described in these docs as a "slice".
183+
184+
:::
185+
186+
Slices will be mounted at their `reducerPath`, and items from reducer map objects will be mounted under their respective key.
187+
188+
```ts
189+
const rootReducer = combineSlices(counterSlice, baseApi, {
190+
user: userSlice.reducer,
191+
auth: authSlice.reducer
192+
})
193+
// is like
194+
const rootReducer = combineReducers({
195+
[counterSlice.reducerPath]: counterSlice.reducer,
196+
[baseApi.reducerPath]: baseApi.reducer,
197+
user: userSlice.reducer,
198+
auth: authSlice.reducer
199+
})
200+
```
201+
202+
:::caution
203+
204+
Be careful to avoid naming collision - later keys will overwrite earlier ones, but Typescript won't be able to account for this.
205+
206+
:::
207+
208+
#### Slice injection
209+
210+
To inject a slice, you should call `rootReducer.inject(slice)` on the reducer instance returned from `combineSlices`. This will inject the slice under its `reducerPath` into the set of reducers, and return an instance of the combined reducer typed to know that the slice has been injected.
211+
212+
Alternatively, you can call `slice.injectInto(rootReducer)`, which returns an instance of the slice which is aware it's been injected. You may even want to do both, as each call returns something useful, and `combineSlices` allows injection of the same reducer instance at the same `reducerPath` without issue.
213+
214+
```ts
215+
const withCounterSlice = rootReducer.inject(counterSlice)
216+
const injectedCounterSlice = counterSlice.injectInto(rootReducer)
217+
```
218+
219+
One key difference between typical reducer injection and `combineSlice`'s "meta-reducer" approach is that `replaceReducer` is never called for `combineSlice`. The reducer instance passed to the store doesn't change.
220+
221+
A consequence of this is that no action is dispatched when a slice is injected, and therefore the injected slice's state doesn't show in state immediately. The state will only show in the store's state when an action is dispatched.
222+
223+
However, to avoid selectors having to account for possibly `undefined` state, `combineSlices` includes some useful [selector utilities](#selector-utilities).
224+
225+
#### Declaring lazy loaded slices
226+
227+
In order for lazy loaded slices to show up in the inferred state type, a `withLazyLoadedSlices` helper is provided. This allows you to declare slices you intend to later inject, so they can show up as optional in the state type.
228+
229+
To completely avoid importing the lazy slice into the combined reducer's file, module augmentation can be used.
230+
231+
```ts
232+
// file: reducer.ts
233+
import { combineSlices } from '@reduxjs/toolkit'
234+
import { staticSlice } from './staticSlice'
235+
236+
export interface LazyLoadedSlices {}
237+
238+
export const rootReducer =
239+
combineSlices(staticSlice).withLazyLoadedSlices<LazyLoadedSlices>()
240+
241+
// file: counterSlice.ts
242+
import type { WithSlice } from '@reduxjs/toolkit'
243+
import { createSlice } from '@reduxjs/toolkit'
244+
import { rootReducer } from './reducer'
245+
246+
interface CounterState {
247+
value: number
248+
}
249+
250+
const counterSlice = createSlice({
251+
name: 'counter',
252+
initialState: { value: 0 } as CounterState,
253+
reducers: {
254+
increment: state => void state.value++
255+
},
256+
selectors: {
257+
selectValue: state => state.value
258+
}
259+
})
260+
261+
declare module './reducer' {
262+
// WithSlice utility assumes reducer is under slice.reducerPath
263+
export interface LazyLoadedSlices extends WithSlice<typeof counterSlice> {}
264+
265+
// if it's not, just use a normal key
266+
export interface LazyLoadedSlices {
267+
aCounter: CounterState
268+
}
269+
}
270+
271+
const injectedCounterSlice = counterSlice.injectInto(rootReducer)
272+
const injectedACounterSlice = counterSlice.injectInto(rootReducer, {
273+
reducerPath: 'aCounter'
274+
})
275+
```
276+
277+
#### Selector utilities
278+
279+
As well as `inject`, the combined reducer instance has a `.selector` method which can be used to wrap selectors. It wraps the state object in a `Proxy`, and provides an initial state for any reducers which have been injected but haven't appeared in state yet.
280+
281+
The result of calling `inject` is typed to know that the injected slice will always be defined when the selector is called.
282+
283+
```ts
284+
const selectCounterValue = (state: RootState) => state.counter?.value // number | undefined
285+
286+
const withCounterSlice = rootReducer.inject(counterSlice)
287+
const selectCounterValue = withCounterSlice.selector(
288+
state => state.counter.value // number - initial state used if not in store
289+
)
290+
```
291+
292+
An "injected" instance of a slice will do the same thing for slice selectors - initial state will be provided if not present in the state passed.
293+
294+
```ts
295+
const injectedCounterSlice = counterSlice.injectInto(rootReducer)
296+
297+
console.log(counterSlice.selectors.selectValue({})) // runtime error
298+
console.log(injectedCounterSlice.selectors.selectValue({})) // 0
299+
```
300+
301+
#### Typical usage
302+
303+
`combineSlices` is designed so that the slice is injected as soon as it's needed (i.e. a selector or action is imported from a component that's been loaded in).
304+
305+
This means that the typical usage will look something along the lines of the below.
306+
307+
```ts
308+
// file: reducer.ts
309+
import { combineSlices } from '@reduxjs/toolkit'
310+
import { staticSlice } from './staticSlice'
311+
312+
export interface LazyLoadedSlices {}
313+
314+
export const rootReducer =
315+
combineSlices(staticSlice).withLazyLoadedSlices<LazyLoadedSlices>()
316+
317+
// file: store.ts
318+
import { configureStore } from '@reduxjs/toolkit'
319+
import { rootReducer } from './reducer'
320+
321+
export const store = configureStore({ reducer: rootReducer })
322+
323+
// file: counterSlice.ts
324+
import type { WithSlice } from '@reduxjs/toolkit'
325+
import { createSlice } from '@reduxjs/toolkit'
326+
import { rootReducer } from './reducer'
327+
328+
const counterSlice = createSlice({
329+
name: 'counter',
330+
initialState: { value: 0 },
331+
reducers: {
332+
increment: state => void state.value++
333+
},
334+
selectors: {
335+
selectValue: state => state.value
336+
}
337+
})
338+
339+
export const { increment } = counterSlice.actions
340+
341+
declare module './reducer' {
342+
export interface LazyLoadedSlices extends WithSlice<typeof counterSlice> {}
343+
}
344+
345+
const injectedCounterSlice = counterSlice.injectInto(rootReducer)
346+
347+
export const { selectValue } = injectedCounterSlice.selectors
348+
349+
// file: Counter.tsx
350+
// by importing from counterSlice we guarantee
351+
// the injection happens before this component is defined
352+
import { increment, selectValue } from './counterSlice'
353+
import { useAppDispatch, useAppSelector } from './hooks'
354+
355+
export default function Counter() {
356+
const dispatch = usAppDispatch()
357+
const value = useAppSelector(selectValue)
358+
return (
359+
<>
360+
<p>{value}</p>
361+
<button onClick={() => dispatch(increment())}>Increment</button>
362+
</>
363+
)
364+
}
365+
366+
// file: App.tsx
367+
import { Provider } from 'react-redux'
368+
import { store } from './store'
369+
370+
// lazily importing the component means that the code
371+
// doesn't actually get pulled in and executed until the component is rendered.
372+
// this means that the inject call only happens once Counter renders
373+
const Counter = React.lazy(() => import('./Counter'))
374+
375+
function App() {
376+
return (
377+
<Provider store={store}>
378+
<Counter />
379+
</Provider>
380+
)
381+
}
382+
```
383+
384+
### `createDynamicMiddleware`
385+
386+
The `createDynamicMiddleware` utility creates a "meta-middleware" which allows for injection of middleware after store initialisation.
387+
388+
```ts
389+
import { createDynamicMiddleware, configureStore } from '@reduxjs/toolkit'
390+
import logger from 'redux-logger'
391+
import reducer from './reducer'
392+
393+
const dynamicMiddleware = createDynamicMiddleware()
394+
395+
const store = configureStore({
396+
reducer,
397+
middleware: getDefaultMiddleware =>
398+
getDefaultMiddleware().concat(dynamicMiddleware.middleware)
399+
})
400+
401+
dynamicMiddleware.addMiddleware(logger)
402+
```
403+
404+
#### `addMiddleware`
405+
406+
`addMiddleware` appends the middleware instance to the chain of middlewares handled by the dynamic middleware instance. Middleware is applied in injection order, and stored by function reference (so the same middleware is only applied once regardless of how many times it's injected).
407+
408+
:::note
409+
410+
It's important to remember that all middlewares injected will be contained _within_ the original dynamic middleware instance.
411+
412+
```ts
413+
import { createDynamicMiddleware, configureStore } from '@reduxjs/toolkit'
414+
import logger from 'redux-logger'
415+
import reducer from './reducer'
416+
417+
const dynamicMiddleware = createDynamicMiddleware()
418+
419+
const store = configureStore({
420+
reducer,
421+
middleware: getDefaultMiddleware =>
422+
getDefaultMiddleware().concat(dynamicMiddleware.middleware)
423+
})
424+
425+
dynamicMiddleware.addMiddleware(logger)
426+
427+
// middleware chain is now [thunk, logger]
428+
```
429+
430+
If it's desired to have more control over the order, multiple instances can be used.
431+
432+
```ts
433+
import { createDynamicMiddleware, configureStore } from '@reduxjs/toolkit'
434+
import logger from 'redux-logger'
435+
import reducer from './reducer'
436+
437+
const beforeMiddleware = createDynamicMiddleware()
438+
const afterMiddleware = createDynamicMiddleware()
439+
440+
const store = configureStore({
441+
reducer,
442+
middleware: getDefaultMiddleware =>
443+
getDefaultMiddleware()
444+
.prepend(beforeMiddleware.middleware)
445+
.concat(afterMiddleware.middleware)
446+
})
447+
448+
beforeMiddleware.addMiddleware(logger)
449+
afterMiddleware.addMiddleware(logger)
450+
451+
// middleware chain is now [logger, thunk, logger]
452+
```
453+
454+
:::
455+
456+
#### `withMiddleware`
457+
458+
`withMiddleware` is an action creator which, when dispatched, causes the middleware to add any middlewares included and returns a pre-typed version of `dispatch` with any added extensions.
459+
460+
```ts
461+
const listenerDispatch = store.dispatch(
462+
withMiddleware(listenerMiddleware.middleware)
463+
)
464+
465+
const unsubscribe = listenerDispatch(addListener({ actionCreator, effect }))
466+
// ^? () => void
467+
```
468+
469+
This is mainly useful in a non-React context. With React it's more useful to use the [react integration](#react-integration).
470+
471+
#### React integration
472+
473+
When imported from the `@reduxjs/toolkit/react` entry point, the instance of dynamic middleware will have a couple of additional methods attached.
474+
475+
##### `createDispatchWithMiddlewareHook`
476+
477+
This method calls `addMiddleware` and returns a version of `useDispatch` typed to know about the injected middleware.
478+
479+
```ts
480+
import { createDynamicMiddleware } from '@reduxjs/toolkit/react'
481+
482+
const dynamicMiddleware = createDynamicMiddleware()
483+
484+
const useListenerDispatch = dynamicMiddleware.createDispatchWithMiddlewareHook(
485+
listenerMiddleware.middleware
486+
)
487+
488+
function Component() {
489+
const dispatch = useListenerDispatch()
490+
491+
useEffect(() => {
492+
const unsubscribe = dispatch(addListener({ actionCreator, effect }))
493+
return unsubscribe
494+
}, [dispatch])
495+
}
496+
```
497+
498+
:::caution
499+
500+
Middleware is injected when `createDispatchWithMiddlewareHook` is called, _not_ when the `useDispatch` hook is called.
501+
502+
:::
503+
504+
##### `createDispatchWithMiddlewareHookFactory`
505+
506+
This method take a React context instance and creates an instance of `createDispatchWithMiddlewareHook` which uses that context. (see [Providing custom context](https://react-redux.js.org/using-react-redux/accessing-store#providing-custom-context))
507+
508+
```ts
509+
import { createContext } from 'react'
510+
import { createDynamicMiddleware } from '@reduxjs/toolkit/react'
511+
import type { ReactReduxContextValue } from 'react-redux'
512+
513+
const context = createContext<ReactReduxContextValue | null>(null)
514+
515+
const dynamicMiddleware = createDynamicMiddleware()
516+
517+
const createDispatchWithMiddlewareHook =
518+
dynamicMiddleware.createDispatchWithMiddlewareHookFactory(context)
519+
520+
const useListenerDispatch = createDispatchWithMiddlewareHook(
521+
listenerMiddleware.middleware
522+
)
523+
524+
function Component() {
525+
const dispatch = useListenerDispatch()
526+
527+
useEffect(() => {
528+
const unsubscribe = dispatch(addListener({ actionCreator, effect }))
529+
return unsubscribe
530+
}, [dispatch])
531+
}
532+
```
533+
534+
## Third-party Libraries and Frameworks
158535

159-
There are a few good libraries out there that can help you add the above functionality automatically:
536+
There are a few good external libraries out there that can help you add the above functionality automatically:
160537

161538
- [Redux Ecosystem Links: Reducers - Dynamic Reducer Injection](https://github.com/markerikson/redux-ecosystem-links/blob/master/reducers.md#dynamic-reducer-injection)

0 commit comments

Comments
 (0)