Après avoir maîtrisé les crochets, de nombreux développeurs de React ont connu l'euphorie, obtenant enfin une boîte à outils simple et pratique qui vous permet d'implémenter des tâches avec beaucoup moins de code. Mais cela signifie-t-il que les crochets standard useState et useReducer proposés hors de la boîte sont tout ce dont nous avons besoin pour gérer l'état?
À mon avis, dans leur forme brute, leur utilisation n'est pas très pratique, ils peuvent plus probablement être considérés comme la base pour construire des crochets de gestion d'état vraiment pratiques. Les développeurs de React eux-mêmes encouragent fortement le développement de crochets personnalisés, alors pourquoi ne pas le faire? Sous la coupe, nous examinerons un exemple très simple et compréhensible de ce qui ne va pas avec les crochets ordinaires et comment ils peuvent être améliorés, à tel point qu'ils refusent complètement de les utiliser sous leur forme pure.
Il y a un certain champ pour entrer, conditionnellement, un nom. Et il y a un bouton en cliquant sur lequel on doit faire une demande au serveur avec le nom saisi (une certaine recherche). Il semblerait que cela pourrait être plus facile? Cependant, la solution est loin d'être évidente. La première implémentation naïve:
const App = () => { const [name, setName] = useState(''); const [request, setRequest] = useState(); const [result, setResult] = useState(); useEffect(() => { fetch('//example.api/' + name).then((data) => { setResult(data.result); }); }, [request]); return <div> <input onChange={e => setName(e.target.value)}/> <input type="submit" value="Check" onClick={() => setRequest(name)}/> { result && <div>Result: { result }</div> } </div>; }
Qu'est-ce qui ne va pas ici? Si l'utilisateur, saisissant quelque chose dans le champ, envoie le formulaire deux fois, seule la première demande fonctionnera pour nous, car au deuxième clic, la demande ne changera pas et useEffect ne fonctionnera pas. Si nous imaginons que notre application est un service de recherche de billets, et que l'utilisateur peut à certains intervalles envoyer le formulaire encore et encore sans apporter de modifications, alors une telle implémentation ne fonctionnera pas pour nous! L'utilisation du nom comme dépendance pour useEffect est également inacceptable, sinon le formulaire sera envoyé immédiatement lorsque le texte change. Eh bien, vous devez faire preuve d'ingéniosité.
const App = () => { const [name, setName] = useState(''); const [request, setRequest] = useState(); const [result, setResult] = useState(); useEffect(() => { fetch('//example.api/' + name).then((data) => { setResult(data.result); }); }, [request]); return <div> <input onChange={e => setName(e.target.value)}/> <input type="submit" value="Check" onClick={() => setRequest(!request)}/> { result && <div>Result: { result }</div> } </div>; }
Maintenant, à chaque clic, nous changerons la signification de la demande à l'opposé, ce qui permettra d'obtenir le comportement souhaité. C'est une béquille très petite et innocente, mais cela rend le code quelque peu déroutant à comprendre. Peut-être maintenant il vous semble que je suce le problème de mon doigt et gonfle son échelle. Eh bien, pour savoir si c'est vrai ou non, vous devez comparer ce code avec d'autres implémentations qui offrent une approche plus expressive.
Regardons cet exemple au niveau théorique en utilisant l'abstraction des threads. Il est très pratique pour décrire l'état des interfaces utilisateur. Nous avons donc deux flux: les données saisies dans le champ de texte (nom $), et un flux de clics sur le bouton d'envoi du formulaire (cliquez sur $). À partir d'eux, nous devons créer un troisième flux combiné de demandes vers le serveur.
name$ __(C)____(Ca)_____(Car)____________________(Carl)___________ click$ ___________________________()______()________________()_____ request$ ___________________________(Car)___(Car)_____________(Carl)_
Voici le comportement que nous devons atteindre. Chaque flux a deux aspects: la valeur qu'il possède et le moment auquel les valeurs le traversent. Dans diverses situations, nous pouvons avoir besoin de l'un ou l'autre aspect, ou des deux. Vous pouvez comparer cela avec le rythme et l'harmonie de la musique. Les flux pour lesquels seul le temps de réponse est essentiel sont également appelés signaux.
Dans notre cas, le clic $ est un signal pur: peu importe la valeur qui le traverse (indéfini / vrai / événement / quoi que ce soit), il n'est important que lorsque cela se produit. Nom de cas $
le contraire: ses changements n'entraînent aucun changement dans le système, mais nous pourrions avoir besoin de sa signification à un moment donné. Et à partir de ces deux flux, nous devons faire le troisième, en prenant de la première fois, de la deuxième valeur.
Dans le cas de Rxjs, nous avons un opérateur presque prêt à l'emploi pour cela:
const names$ = fromEvent(...); const click$ = fromEvent(...); const request$ = click$.pipe(withLatestFrom(name$), map(([name]) => fromPromise(fetch(...))));
Cependant, l'utilisation pratique de Rx dans React peut être assez gênante. Une option plus appropriée est la bibliothèque mrr , construite sur les mêmes principes fonctionnels-réactifs que Rx, mais spécialement adaptée pour une utilisation avec React sur le principe de la "réactivité totale" et connectée comme un crochet.
import useMrr from 'mrr/hooks'; const App = props => { const [state, set] = useMrr(props, { result: [name => fetch('//example.api/' + name).then(data => data.result), '-name', 'submit'], }); return <div> <input value={state.name} onChange={set('name')}/> <input type="submit" value="Check" onClick={set('submit')}/> { state.result && <div>Result: { state.result }</div> } </div>; }
L'interface useMrr est similaire à useState ou useReducer: elle retourne un objet d'état (valeurs de tous les threads) et un setter afin de mettre des valeurs dans les threads. Mais à l'intérieur, tout est un peu différent: chaque champ d'état (= stream), à l'exception de ceux dans lesquels nous mettons des valeurs directement à partir des événements DOM, est décrit par une fonction et une liste de threads parents, dont le changement entraînera le recalcul de l'enfant. Dans ce cas, les valeurs des threads parents seront substituées dans la fonction. Si nous voulons simplement obtenir la valeur du flux, mais ne pas répondre à son changement, alors nous écrivons un "moins" devant le nom, comme dans le cas du nom.
Nous avons obtenu le comportement souhaité, essentiellement, en une seule ligne. Mais ce n'est pas seulement de la brièveté. Comparons plus en détail les résultats obtenus, et tout d'abord par rapport à un paramètre tel que la lisibilité et la clarté du code résultant.
Dans mrr, vous pourrez séparer presque complètement la «logique» du «modèle»: vous n'aurez pas à écrire de gestionnaires impératifs complexes dans JSX. Tout est extrêmement déclaratif: nous mappons simplement l'événement DOM au flux correspondant, pratiquement sans conversion (pour les champs d'entrée, la valeur e.target.value est extraite automatiquement, sauf indication contraire), et déjà dans la structure useMrr nous décrivons comment les flux de base sont formés filiales. Ainsi, dans le cas de transformations de données synchrones et asynchrones, nous pouvons toujours facilement suivre la façon dont notre valeur est formée.
Comparé à Px: nous n'avions même pas besoin d'utiliser des opérateurs supplémentaires: si, par conséquent, les fonctions mrr reçoivent une promesse, elles attendront automatiquement jusqu'à ce qu'elles se résolvent et mettent les données reçues dans le flux. De plus, au lieu de withLatestFrom, nous avons utilisé
écoute passive (signe moins), ce qui est plus pratique. Imaginez qu'en plus du nom, nous devrons envoyer d'autres champs. Ensuite, dans mrr, nous ajouterons un autre flux à écoute passive:
result: [(name, surname) => fetch(...), '-name', '-surname', 'submit'],
Et dans Rx, vous devez en sculpter un de plus avec LatestFrom avec une carte, ou combiner d'abord le nom et le prénom en un seul flux.
Mais revenons aux crochets et mrr. Un enregistrement plus lisible des dépendances, qui montre toujours comment les données sont formées, est peut-être l'un des principaux avantages. L'interface useEffect actuelle ne permet fondamentalement pas de répondre aux flux de signaux, c'est pourquoi
Je dois trouver des rebondissements différents.
Un autre point est que l'option des crochets ordinaires comporte des rendus supplémentaires. Si l'utilisateur vient de cliquer sur le bouton, cela n'entraîne pas encore de modifications de l'interface utilisateur que la réaction doit dessiner. Cependant, un rendu sera appelé. Dans la variante avec mrr, l'état retourné ne sera mis à jour que lorsqu'une réponse du serveur est déjà arrivée. Économiser sur les matchs, dites-vous? Eh bien, peut-être. Mais pour moi personnellement, le principe de «se restituer dans toute situation incompréhensible», qui est à la base des crochets de base, provoque le rejet.
Les rendus supplémentaires signifient une nouvelle formation de gestionnaires d'événements. Au fait, ici, les crochets ordinaires sont tous mauvais. Non seulement les gestionnaires sont impératifs, mais ils doivent également être régénérés à chaque rendu. Et il ne sera pas possible d'utiliser pleinement la mise en cache ici, car de nombreux gestionnaires doivent être verrouillés sur les variables de composants internes. Les gestionnaires mrr sont plus déclaratifs et la mise en cache est déjà intégrée à mrr: set ('name') ne sera généré qu'une seule fois et sera remplacé à partir du cache pour les rendus suivants.
Avec une augmentation de la base de code, les gestionnaires impératifs peuvent devenir encore plus encombrants. Disons que nous devons également indiquer le nombre de soumissions de formulaires effectuées par l'utilisateur.
const App = () => { const [request, makeRequest] = useState(); const [name, setName] = useState(''); const [result, setResult] = useState(false); const [clicks, setClicks] = useState(0); useEffect(() => { fetch('//example.api/' + name).then((data) => { setResult(data.result); }); }, [request]); return <div> <input onChange={e => setName(e.target.value)}/> <input type="submit" value="Check" onClick={() => { makeRequest(!request); setClicks(clicks + 1); }}/><br /> Clicked: { clicks } </div>; }
Pas très beau. Vous pouvez bien sûr rendre le gestionnaire en tant que fonction distincte à l'intérieur du composant. La lisibilité augmentera, mais le problème de la régénération de la fonction à chaque rendu restera, ainsi que le problème de l'impérativité. En substance, il s'agit d'un code procédural régulier, malgré la croyance répandue que l'API React évolue progressivement vers une approche fonctionnelle.
À ceux à qui l'ampleur du problème semble exagérée, je peux répondre que, par exemple, les développeurs du React eux-mêmes sont conscients du problème de la génération excessive de gestionnaires, nous offrant immédiatement une béquille sous la forme d'utilisation de Callback.
Sur mrr:
const App = props => { const [state, set] = useMrr(props, { $init: { clicks: 0, }, isValid: [name => fetch('//example.api/' + name).then(data => data.isValid), '-name', 'makeRequest'], clicks: [a => a + 1, '-clicks', 'makeRequest'], }); return <div> <input onChange={set('name')}/> <input type="submit" value="Check" onClick={set('makeRequest')}/> </div>; }
UseReducer est une alternative plus pratique, vous permettant d'abandonner l'impératif des gestionnaires. Mais d'autres problèmes importants demeurent: le manque de travail avec les signaux (puisque le même useEffect sera responsable des effets secondaires), ainsi que la pire lisibilité lors des conversions asynchrones (en d'autres termes, il est plus difficile de tracer la relation entre les champs du magasin, en raison du même useEffect ) Si dans mrr le graphique de dépendance entre les champs d'état (threads) est immédiatement clairement visible, dans les crochets, vous devez tourner les yeux de haut en bas un peu.
En outre, le partage de useState et useReducer dans le même composant n'est pas très pratique (là encore, il y aura des gestionnaires impératifs complexes qui changeront quelque chose dans useState
et action d'envoi), à cause de quoi, très probablement, avant de développer le composant, vous devrez accepter l'une ou l'autre option.
Bien entendu, l'examen de tous les aspects peut encore se poursuivre. Afin de ne pas dépasser le cadre de l'article, j'aborderai en détail certains points moins importants.
Journalisation centralisée, débogage. Étant donné que dans mrr, tous les flux sont contenus dans un concentrateur, pour le débogage, il suffit d'ajouter un indicateur:
const App = props => { const [state, set] = useMrr(props, { $log: true, $init: { clicks: 0, }, isValid: [name => fetch('//example.api/' + name).then(data => data.isValid), '-name', 'makeRequest'], clicks: [a => a + 1, '-clicks', 'makeRequest'], }); ...
Après cela, toutes les modifications apportées aux flux seront affichées dans la console. Pour accéder à l'état entier (c'est-à-dire aux valeurs actuelles de tous les threads), il existe un pseudo-flux $ state:
a: [({ name, click, result }) => { ... }, '$state', 'click'],
Ainsi, si vous avez besoin ou si vous êtes très habitué au style éditorial, vous pouvez écrire dans le style de l'éditeur dans mrr, en retournant une nouvelle valeur de champ en fonction de l'événement et de tout l'état précédent. Mais l'inverse (écrire sur useReducer ou un éditeur dans le style mrr) ne fonctionnera pas, faute de réactivité.
Travaillez avec le temps. Rappelez-vous deux aspects des flux: le sens et le temps de réponse, l'harmonie et le rythme? Donc, travailler avec le premier dans les crochets ordinaires est assez simple et pratique, mais avec le second - non. En travaillant dans le temps, j'entends la formation de filières d'enfants, dont le «rythme» est différent du parent. Il s'agit principalement de toutes sortes de filtres, debowns, trotl, etc. Tout cela, vous devrez probablement le mettre en œuvre vous-même. Dans mrr, vous pouvez utiliser des instructions prédéfinies prêtes à l'emploi. Le gentleman set mrr est inférieur à la variété d'opérateurs Rx, mais il a une dénomination plus intuitive.
Interaction entre les composants. Je me souviens que dans l'éditeur, il était considéré comme une bonne pratique de créer une seule histoire. Si nous utilisons useReducer dans de nombreux composants,
Il peut y avoir un problème d'organisation de l'interaction entre les parties. Sur mrr, les flux peuvent librement «circuler» d'un composant à un autre, que ce soit vers le haut ou vers le bas de la hiérarchie, mais cela ne créera pas de problèmes en raison de l'approche déclarative. Plus de détails
cette rubrique, ainsi que d'autres fonctionnalités de l'API mrr, sont décrites dans l'article Acteurs + FRP dans React
Conclusions
Les nouveaux crochets React sont excellents et simplifient nos vies, mais ils présentent certains défauts qu'un crochet polyvalent de niveau supérieur (gestion de l'état) peut corriger. UseMrr de la bibliothèque mrr fonctionnelle-réactive a été proposé et considéré comme tel.
Problèmes et leurs solutions:
- recomptages inutiles des données à chaque rendu (en mrr sont absents en raison de la réactivité basée sur la poussée)
- rendus supplémentaires lorsqu'un changement d'état n'entraîne pas de changement dans l'interface utilisateur
- mauvaise lisibilité du code avec les conversions asynchrones (par rapport aux conversions synchrones). Dans mrr, le code asynchrone n'est pas inférieur au synchrone en termes de lisibilité et d'expressivité. La plupart des problèmes discutés dans un récent article sur useEffect sur mrr sont fondamentalement impossibles
- gestionnaires impératifs qui ne sont pas toujours mis en cache (dans mrr, ils sont automatiquement mis en cache, presque toujours peuvent être mis en cache, déclaratifs)
- utiliser useState et useReducer en même temps peut créer un code gênant
- manque d'outils pour convertir les flux dans le temps (anti-rebond, accélérateur, condition de course)
Sur de nombreux points, on peut affirmer qu'ils peuvent être résolus par des crochets personnalisés. Mais c'est précisément ce qui est proposé, mais au lieu d'implémentations disparates, pour chaque tâche distincte, une solution globale et cohérente est proposée.
De nombreux problèmes sont devenus trop familiers pour que nous soyons clairement reconnus. Par exemple, les conversions asynchrones ont toujours semblé plus compliquées et déroutantes que les conversions synchrones, et les crochets en ce sens ne sont pas pires que les approches antérieures (éditeurs, etc.). Pour réaliser cela comme un problème, vous devez d'abord voir d'autres approches qui offrent une meilleure solution.
Cet article ne vise pas à imposer des vues spécifiques, mais plutôt à attirer l'attention sur le problème. Je suis sûr que d'autres solutions existent ou sont en cours de création qui peuvent devenir une alternative valable, mais qui ne sont pas encore largement connues. L'API React Cache à venir peut également faire une grande différence. Je me ferai un plaisir de critiquer et de discuter dans les commentaires.
Les personnes intéressées peuvent également regarder une présentation sur ce sujet sur kyivjs le 28 mars.