22'
Simplifier Redux avec Redux-Zap ⚡
Godefroy de Compreignac
Vous connaissez Redux
(Sinon vous risquez de vous faire bien chier pendant 22 min)
Action
Reducer
Store
UI
dispatch
subscribe
emit new state
apply
Redux
Les hooks et les typings de react-redux ont bien amélioré le développement côté composants
Côté actions et reducers,
c'est souvent plus compliqué.
On a besoin de rajouter des middlewares et d'écrire beaucoup de boilerplate.
On utilise souvent :
- @reduxjs/toolkit
- redux-thunk
- redux-saga
Mais on sent que c'est vite compliqué pour pas grand chose.
Redux
Action
Reducer
Store
UI
dispatch
subscribe
emit new state
apply
Lots of
boilerplate code
Redux est puissant
mais verbeux
Et ça peut vite
vous péter à la gueule

- Typage compliqué entre les actions et reducers
- Reducers non utilisés difficilement identifiés
- Actions asynchrones bancales
La plupart du temps, on écrit un cas de reducer par action creator, et on s'arrange pour les ranger au même endroit.
Alors pourquoi ne pas, la plupart du temps, combiner les deux directement ?

- Tu vas pas nous commettre une hérésie là ?
- Oui, et alors ?

Les objectifs de redux-zap :
- Code plus simple
- Respect du principe d'immutabilité
- Asynchronicité par défaut
- Pas de dépendances autres que redux et redux-thunk
- Typage complet avec inférence maximum
- Utilisation progressive / partielle
Je vais vous montrer comment j'ai procédé...
Pour concevoir redux-zap,
j'ai d'abord écrit une webapp de test
avec le store écrit comme j'aimerais qu'on puisse l'écrire.
On crée un fichier counter.ts
qui contiendra le store "counter"
Partons d'un state,
après tout c'est de cela qu'il s'agit
interface State {
readonly count: number
readonly counting: boolean
}
const initialState: State = {
count: 0,
counting: false
}Qu'est-ce que fait une action en général ?
- Effets de bord (appel API...)
- Modification(s) partielle d'un state avec dispatch
- Eventuellement de manière asynchrone
On modifie rarement un state entièrement, contentons-nous d'émettre un state partiel.
Cas simple :
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
}Avec 1 argument :
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.
Les générateurs asynchrones

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
ou alors...
- À la place du state partiel, pouvoir émettre une fonction qui accepte un state en argument et qui retourne un state partiel.
- Accéder au state initial (à l'exécution de l'action)
par this
⚠ à éviter dans la mesure du possible
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 }
}La plupart des cas sont
maintenant couverts.
Maintenant comment
on assemble tout ça ?
Du typing pour représenter nos actions :
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".
Ensuite il nous faut un moyen pour assembler le state initial et les zaps,
avec pour objectifs :
- Inférer au maximum les types
- Obtenir un reducer
- Obtenir un type de state
- Obtenir des actions redux
Imaginons donc une fonction qu'on pourrait appeler comme ça :
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
OK, j'ai écrit mon app comme j'aimerais pouvoir l'écrire, maintenant il faut de la magie derrière pour que ça marche

Le trick, c'est de stocker
le StateTransform
(state partiel ou fonction qui renvoie un state partiel)
dans l'objet d'action transmis aux reducer, qui n'a alors plus qu'à l'appliquer
Le reducer n'a alors plus qu'à appliquer le state partiel au state actuel pour obtenir et renvoyer un nouveau state.
C'est sale, mais ça marche !
Action
with state transform
Zap
to action
Store
State transform
Reducer
dispatch
dispatch zap
apply
Redux-zap
Le code de redux-zap est très simple, léger et performant
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.
Mais ça couvre 99% des cas d'usage, et pour le reste on peut écrire des actions/reducers classiques.
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...
Demo
(s'il reste du temps)
C'est tout
pour aujourd'hui 🧡

Godefroy de Compreignac
Twitter : @Godefroy
22' - Simplifier Redux avec Redux-Zap ⚡
By lonestone
22' - Simplifier Redux avec Redux-Zap ⚡
- 404