Penser en React

React est une lib (et pas un framework)

Built-in

- Mécanique et lifecycle des composants

- Logique MVVM

Third-party

- Manipulation du DOM (react-dom)

- Gestion d'un state global (redux)

- Gestion du réseau (fetch, axios / superagent, react-query)

- Code métier

React est un MVVM (et pas un MVC)

Model

View

view = f(model)
g(model, event)

View code

Callback code

View code

La vue est une fonction pure du modèle :

  • aux mêmes arguments renvoie toujours le même résultat
  • ne déclenche pas d'effet de bord.

View code

La fonction de vue se développe par composition.

const view = App(model)

function App(model: FullAppModel) -> View {
  const {sidebarModel, contentModel} = model
  
  return (
    <>
      {Sidebar(sidebarModel)}
      {Content(contentModel)}
    </>
  )
}

function Sidebar(model: SidebarModel) -> View {
  // ...
}
function Content(model: SidebarModel) -> View {
  // ...
}
// et ainsi de suite.

Callback code

Réagir aux événements du DOM

<Button onClick={handleClick} />

async function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
  // Pas de return mais des effets ici :
 
  // mettre un state local
  setClickCounter(clickCounter => clickCounter + 1)
  
  // déclencher un effet de bord
  const result = await submitForm()
  setResult(result)
}

Pas une fonction pure, au contraire, un "effet pur".

Et quand ça ne suffit pas ?

Dans la vraie vie, on a aussi besoin :

  • de state local (et plus ou moins local)
  • d'effets de bord qui se déclenchent sur des étapes de lifecycle

Le state local

Attacher une variable à mémoriser avec une instance d'un composant :

Pareil, mais en déclenchant un rendu quand on met à jour la valeur :

Pareil, mais avec une logique de reducer pour calculer la prochaine valeur à partir de la valeur actuelle et du trigger :

const clickCounter = useRef<number>(0)
const [clickCounter, setClickCounter] = useState<number>(0)
function reducer(state: number, event: React.MouseEvent) -> number {
  switch (event.type) {
    case 'DoubleClick':
      return state + 2
    case 'Click':
      return state + 1
    default:
      return state
  }
}

const [clickCounter, dispatch] = useReducer<number>(reducer, 0)

<Button onClick={dispatch} />

Le state moins local (1/2)

Création d'un Contexte :

export const ClickCounterContext = React.createContext<number | undefined>()

Fournir la valeur du contexte dans une partie de l'arborescence des composants :

function ClickCounterPage() {
  const [clickCounter, setClickCounter] = useState<number>(0)
  
  return (
    <>
      <ClickCounterContext.Provider value={clickCounter}>
        <SomeOtherComponent />
      </ClickCounterContext.Provider>
      <Button onClick={() => setClickCounter(clickCounter => clickCounter + 1)} />
    </>
  )
}

Le state moins local (2/2)

Utilisation type-safe du contexte :

function useClickCounter() -> number {
  const clickCounter = useContext(ClickCounterContext)

  // fait l'assertion que clickCounter est toujours défini
  if (clickCounter === undefined) {
    throw new Error('useClickCounter used outside ClickCounterContext.Provider')
  }

  return clickCounter
}

function SomeOtherComponent() {
  const counter = useClickCounter() // number

  return <span>Count is {counter}</span>
}

Utilisation basique du contexte :

function SomeOtherComponent() {
  const counter = useContext(ClickCounterContext)
  if (counter === undefined) {
    return null
  }

  return <span>Count is {counter}</span>
}

Le state vraiment pas local

Redux :

const ReduxContext = React.createContext<StateShape>(initialState)

function ReduxStore({ children }) {
  const [state, dispatch] = useReducer<StateShape>(mainReducer, initialState)
  
  return (
    <ReduxContext.Provider value={state}>
      {children}
    </ReduxContext.Provider>  
  )
}

function useSelector<T>(selectorFn: (state: StateShape) => T): T {
  const fullState = useContext(ReduxContext)
  
  return selectorFn(fullState)
}
function MyApp() {

  return (
    <ReduxStore>
      <Stuff ... />
    </ReduxStore>
  )
}

Les effets de bord (1/2)

Exemple de connexion / déconnexion à un event listener

function MySizeAwareComponent() {
  const elt = useRef<HTMLDivElement>()
  const [width, setWidth] = useState<number>(0)
  
  useEffect(() => {
    if (!elt.current) return
    
    const observer = new ResizeObserver(
      (entries) => setWidth(entries[0].contentBoxSize[0])
    )
    observer.observe(elt.current)
    
    return () => {
      if (!elt.current) return
      
      observer.unobserve(elt.current)
    }
  }, []) // pas de dépendance car setWidth et ref sont stables

  return (
    <div ref={elt}>
      My width is {width}.
    </div>  
  )
}

Les effets de bord (2/2)

Exemple de récupérer de la data en fonction d'un ID en prop

function MyDataComponent({ id }) {
  const [data, setData] = useState<any>(null)
  
  useEffect(() => {
    async function fetchData(id: number) {
      const data = await fetchFromBackend(id)
      setData(data)
    }
    
    fetchData(id)
    
    return () => {
      // ici il faudrait annuler la Promise de sorte à ce que
      // setData ne soit pas appelée si la Promise est obsolète
    }
  }, [id])

  return (
    <pre>
      {JSON.stringify(data, null, 2)}
    </pre>  
  )
}

Focus sur les dépendances de useEffect (1/6)

Elles sont comparées via Object.is. Si l'une des dépendances change d'un rendu à l'autre selon Object.is, alors on lance la fonction de cleanup avec les anciennes valeurs puis la fonction d'effet avec les nouvelles.

useEffect(() => {
  async function fetchData() {
    const data = await fetchFromBackend(id, params)
    setData(data)
  }
    
  fetchData()
    
  return () => {
    // here we should abort the promise so that setData is not called
    // after it finishes if it is outdated
  }
}, [id, params, fetchFromBackend])

Focus sur les dépendances de useEffect (2/6)

useEffect(() => {
  async function fetchData() {
    const data = await fetchFromBackend(id, params)
    setData(data)
  }
    
  fetchData()
}, [id, params, fetchFromBackend])
  • id est un type primitif. Il change d'après Object.is si et seulement si sa valeur change.
  • params est un objet. Il change d'après Object.is quand la référence change, donc dès qu'il est redéfini, même si son contenu n'a pas changé.
  • fetchFromBackend est une fonction, donc un objet. Elle change d'après Object.is quand la référence change, donc dès qu'elle est redéfinie, même si son code est identique.

Focus sur les dépendances de useEffect (3/6)

async function fetchFromBackend(id) {
  return await APIClient.get(`/api/endpoint/${id}`)
}

function MyComponent() {
  async function fetchFromBackend(id) {
    return await APIClient.get(`/api/endpoint/${id}`)
  }

  useEffect(() => {
    async function fetchData() {
      const data = await fetchFromBackend(id)
      setData(data)
    }
    
    fetchData()
  }, [id, fetchFromBackend])
}

Quelle est la différence entre les deux définitions de fetchFromBackend, en ce qui concerne l'exécution du useEffect ?

Ici, la 2e fetchFromBackend est instable, nouvelle à chaque rendu, et ce sans raison légitime.

Focus sur les dépendances de useEffect (4/6)

function MyComponent({ endpoint }) {
  async function fetchFromBackend(id) {
    return await APIClient.get(`/api/${endpoint}/${id}`)
  }

  useEffect(() => {
    fetchFromBackend(id)
  }, [id, fetchFromBackend])
}

Ici, fetchFromBackend a besoin de la variable endpoint en plus des arguments qu'on lui passe. L'effet et le résultat de cette fonction dépendent, en plus de ses arguments, d'une variable qui existait dans le contexte à sa création, et qu'elle "embarque" avec elle (mécanisme de closure) et utilise lors de l'appel.

Avec la mécanique de closure, pour que l'effet de la fonction change avec le changement de la prop endpoint, il faut recréer la fonction, car la fonction précédemment créée embarque la valeur précédente de endpoint.

Donc à chaque fois que endpoint change, il faut recréer la fonction. Mais pas à chaque rendu !

Focus sur les dépendances de useEffect (5/6)

function MyComponent({ endpoint }) {
  const fetchFromBackend = useMemo(() => {
    return function(id) {
      return APIClient.get(`/api/${endpoint}/${id}`)
    }
  }, [endpoint]);
  
  // équivalent mais plus court
  const fetchFromBackend = useCallback((id) => {
    return APIClient.get(`/api/${endpoint}/${id}`)
  }, [endpoint]);

  useEffect(() => {
    fetchFromBackend(id)
  }, [id, fetchFromBackend])
}

useMemo ou useCallback : utilitaire qui retourne un nouvel objet / fonction lorsque les dépendances changent.

Le useEffect s'exécutera lorsque id ou endpoint changent.

Est-ce que c'est ce qu'on veut ?

Focus sur les dépendances de useEffect (6/6)

function MyComponent({ endpoint }) {
  const fetchFromBackend = useCallback((id) => {
    return APIClient.get(`/api/${endpoint}/${id}`)
  }, [endpoint]);

  useEffect(() => {
    fetchFromBackend(id)
  }, [id, fetchFromBackend])
}

Le useEffect s'exécutera lorsque id ou endpoint changent. Est-ce que c'est ce qu'on veut ?

React part du principe que si la conséquence change, il faut réexécuter l'effet.

Si vous naviguez entre deux pages ayant la même structure de composants mais juste l'ID ou l'endpoint qui changent, alors le composant reste instancié, et sa prop change. Vous avez besoin de refetcher.

A vous de bien indiquer à React, de quelles variables dépendent les conséquences de vos effets. Il sait le déterminer tout seul, sauf pour les objets / fonctions créées au rendu, qu'il faut parfois stabiliser avec useMemo / useCallback.

Les trois espaces de code en React

function MyComponent() {
  const [data, setData] = useState<DataT>()
  
  useEffect(() => {
    async function run(id) {
      const data = await APIClient.get(`/api/endpoint/${id}`)
      setData(data)
    }
    run(id)
  }, [id])
  
  async function onSubmit(formData) {
    const result = await APIClient.put(`/api/endpoint/${id}`, formData)
    setData(result)
  }
  
  return (
    <div>
      <DataDisplay data={data} />
      <DataForm initialData={data} onSubmit={onSubmit} />
    </div>
  )
}
  • Transformer les props et le state en vue
  • Déclarer des states / effets
  • Réagir aux événements du DOM
  • Side effects du lifecycle
  • Réagir aux changements de props

Code de vue

Code de callback

Code d'effet

Quand on se trompe ...

Si vous mettez du code de ➡️ dans du ⬇️ Vue Callback Effet
Vue Boucle infinie Boucle infinie
Callback Ne fait rien Pas assez d'exécutions
Effet Ne fait rien Trop d'exécution

Voilà c'est tout !

Hâte de relire vos PR de front ;)

Références

Thinking in React

By Foucauld Degeorges

Thinking in React

  • 51