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èsObject.is
si et seulement si sa valeur change.params
est un objet. Il change d'aprèsObject.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èsObject.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
- La nouvelle doc react.dev
- Le Guide Pas trop Mal de la Programmation Fonctionnelle du Pr Frisby (traduit en français)
- twitter: @jaredpalmer @kentcdodds @dan_abramov
Thinking in React
By Foucauld Degeorges
Thinking in React
- 89