Godefroy de Compreignac
(Sinon vous risquez de vous faire bien chier pendant 22 min)
Action
Reducer
Store
UI
dispatch
subscribe
emit new state
apply
On a besoin de rajouter des middlewares et d'écrire beaucoup de boilerplate.
Action
Reducer
Store
UI
dispatch
subscribe
emit new state
apply
Lots of
boilerplate code
Je vais vous montrer comment j'ai procédé...
interface State {
readonly count: number
readonly counting: boolean
}
const initialState: State = {
count: 0,
counting: false
}
1 modification partielle du state
const reset = () => ({ count: 0 })1 effet de bord + 1 modification partielle du state
const reset = () => {
log('reset') // Effet de bord
return { count: 0 } // State partiel
}const increment = (n: number) => state => ({ count: state.count + n })On a besoin du state au moment de l'exécution de l'action, faisons en sorte de l'obtenir dans l'argument d'une fonction
Et pour faire une action asynchrone qui émet plusieurs modifications partielles de state ?
Il existe une syntaxe JS idéale pour ça.
Un générateur asynchrone est une fonction asynchrone qui peut émettre plusieurs valeurs (iterator)
async function *doStuff() {
console.log('Reset counter')
yield 0
console.log('Wait for 1s')
await new Promise(resolve => setTimeout(resolve, 1000))
console.log('Set counter to 1')
yield 1
}
for await (const value of doStuff()) {
console.log('Value', value)
}Résultat dans la
console de Chrome :
Imaginons donc une action asynchrone qui puisse émettre plusieurs states partiels
async *incrementAsync() {
yield { count: 0 }
await new Promise(resolve => setTimeout(resolve, 1000))
yield { count: 1 }
}On va vite avoir besoin du state courant.
Pour cela on pourrait :
Passer le state en argument, mais il ne serait vite plus à jour si on réalise des opérations asynchrones
Passer une fonction getState en argument, mais ça polluerait les arguments de l'action
async *incrementAsync() {
// State à l'exécution de l'action
console.log('Previous count:', this.count)
// Reset
yield { count: 0 }
// Appel asynchrone
await new Promise(resolve => setTimeout(resolve, 1000))
// Increment
yield state => { count: state.count + 1 }
}
type StateTransform<State> = Partial<State> | ((state: State) => Partial<State>)
type Zap<State, Params extends []> = (
this: State,
...params: Params
) => StateTransform<State> | AsyncIterableIterator<StateTransform<State>>On s'est tellement éloigné de la forme classique des actions que j'appelle ça maintenant des "zaps".
export default prepareStore(initialState, zapsMap)Ce sera la seule fonction qu'on aura besoin d'importer de redux-zap pour préparer un store.
Pour pouvoir profiter de l'inférence dans les zaps, il est préférable d'écrire les zaps directement dans l'appel de la fonction.
import { prepareStore } from 'redux-zap'
interface State {
readonly count: number
readonly counting: boolean
}
const initialState: State = {
count: 0,
counting: false
}
export default prepareStore(initialState, {
reset: () => ({ count: 0 }),
increment: () => state => ({ count: state.count + 1 }),
decrement: (n: number) => state => ({ count: state.count - n }),
async *incrementAsync() {
console.log('count', this.count)
yield { counting: true }
for (let i = 1; i <= 5; i++) {
yield state => ({ count: state.count + 1 })
await new Promise(resolve => setTimeout(resolve, 500))
}
yield { counting: false }
}
})store/counter.ts
Peu importe ce que renvoie exactement prepareStore, tant que je puisse le combiner avec les autres stores pour obtenir des reducers, des actions, et un state initial utilisable pour redux :
import { combineStores } from 'redux-zap'
import { applyMiddleware, combineReducers, createStore } from 'redux'
import thunk from 'redux-thunk'
import counter from './counter'
import pokemons from './pokemons'
export const { reducers, actions, initialState } = combineStores({
// Prepared stores
counter,
pokemons
})
// Obtain and export full type of the root state
export type RootState = typeof initialState
export default createStore(combineReducers(reducers), applyMiddleware(thunk))store/index.ts
À l'usage, aucune différence avec du redux classique, si ce n'est que les actions sont regroupées dans un objet actions :
import React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { actions, RootState } from '../store'
const { reset, increment, incrementAsync, decrement } = actions.counter
export default function Counter() {
const { count, counting } = useSelector((state: RootState) => state.counter)
const dispatch = useDispatch()
return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch(reset())}>✖</button>
<button onClick={() => dispatch(decrement(3))}>➖3</button>
<button onClick={() => dispatch(decrement(1))}>➖</button>
<button onClick={() => dispatch(increment())}>➕</button>
<button onClick={() => dispatch(incrementAsync())}>➕5{counting && '⏳'}</button>
</div>
)
}components/Counter.tsx
C'est sale, mais ça marche !
Action
with state transform
Zap
to action
Store
State transform
Reducer
dispatch
dispatch zap
apply
100 lignes de code
150 lignes de types
Ce n'est pas parfait, il manque par exemple la possibilité d'intercepter des actions d'autres reducers.
import { prepareStore } from 'redux-zap'
const initialState = {
count: 0,
counting: false
}
export default prepareStore(initialState, {
reset: () => ({ count: 0 }),
increment: () => state => ({ count: state.count + 1 }),
decrement: (n) => state => ({ count: state.count - n }),
async *incrementAsync() {
console.log('count', this.count)
yield { counting: true }
for (let i = 1; i <= 5; i++) {
yield state => ({ count: state.count + 1 })
await new Promise(resolve => setTimeout(resolve, 500))
}
yield { counting: false }
}
})const initialState = {
count: 0,
counting: false
}
// Actions
export const reset = () => ({ type: 'COUNTER_RESET' })
export const increment = () => ({ type: 'COUNTER_INCREMENT' })
export const decrement = (n) => ({ type: 'COUNTER_DECREMENT', n })
export const setCounting = (counting) => ({ type: 'COUNTER_COUNTING', counting })
export function incrementAsync(n) {
return async (dispatch, getState) => {
console.log('initial count', getState().count)
dispatch(setCounting(true))
for (let i = 1; i <= n; i++) {
yield state => ({ count: state.count + 1 })
dispatch(increment())
await new Promise(resolve => setTimeout(resolve, 500))
}
dispatch(setCounting(false))
}
}
// Reducer
export default function counterReducer(state = initialState, action) {
switch (action.type) {
case 'COUNTER_RESET':
return { ...state, count: 0 }
case 'COUNTER_INCREMENT':
return { ...state, count: state.count + 1 }
case 'COUNTER_DECREMENT':
return { ...state, count: state.count - action.n }
case 'COUNTER_COUNTING':
return { ...state, counting: action.counting }
default:
return state
}
}Avec redux-thunk
Avec redux-zap
🡴 Sans typings, sinon ça serait encore plus long...
Godefroy de Compreignac
Twitter : @Godefroy