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