Cum „Regula de aur” a componentelor React vă poate ajuta să scrieți un cod mai bun

Și cum intră în joc cârligele

Recent am adoptat o nouă filozofie care schimbă modul în care fac componente. Nu este neapărat o idee nouă, ci mai degrabă un nou mod subtil de gândire.

Regula de aur a componentelor

Creați și definiți componente în modul cel mai natural, luând în considerare doar ceea ce trebuie să funcționeze.

Din nou, este o afirmație subtilă și s-ar putea să credeți că o urmați deja, dar este ușor să mergeți împotriva acestui lucru.

De exemplu, să presupunem că aveți următoarea componentă:

Dacă ați defini această componentă „natural”, probabil că ați scrie-o cu următorul API:

PersonCard.propTypes = { name: PropTypes.string.isRequired, jobTitle: PropTypes.string.isRequired, pictureUrl: PropTypes.string.isRequired, };

Ceea ce este destul de simplu - uitându-vă exclusiv la ceea ce trebuie să funcționeze, aveți nevoie doar de un nume, titlul postului și adresa URL a imaginii.

Dar să presupunem că aveți cerința de a afișa o imagine „oficială” în funcție de setările utilizatorului. S-ar putea să fiți tentați să scrieți un API astfel:

PersonCard.propTypes = { name: PropTypes.string.isRequired, jobTitle: PropTypes.string.isRequired, officialPictureUrl: PropTypes.string.isRequired, pictureUrl: PropTypes.string.isRequired, preferOfficial: PropTypes.boolean.isRequired, };

Poate părea că componenta are nevoie de acele accesorii suplimentare pentru a funcționa, dar în realitate, componenta nu arată diferit și nu are nevoie de acele accesorii suplimentare pentru a funcționa. Ceea ce fac aceste recuzite suplimentare este să cuplați această preferOfficialsetare cu componenta dvs. și să faceți ca orice utilizare a componentei în afara acelui context să se simtă cu adevărat nefirească.

Reducerea decalajului

Deci, dacă logica pentru comutarea URL-ului imaginii nu aparține componentei în sine, de unde aparține?

Ce zici de un indexfișier?

Am adoptat o structură de dosare în care fiecare componentă intră într-un dosar auto-intitulat în care indexfișierul este responsabil pentru reducerea decalajului dintre componenta dvs. „naturală” și lumea exterioară. Numim acest fișier „container” (inspirat din conceptul de componente „container” al React Redux).

/PersonCard -PersonCard.js ------ the "natural" component -index.js ----------- the "container"

Definim containerele ca o bucată de cod care acoperă acest decalaj între componenta dvs. naturală și lumea exterioară. Din acest motiv, uneori numim aceste lucruri „injectoare”.

Componenta dvs. naturală este codul pe care l-ați crea dacă vi s-ar arăta doar o imagine a ceea ce vi s-a cerut (fără detaliile despre cum ați obține datele sau unde ar fi plasate în aplicație - tot ce știți este că ar trebui să funcționeze).

Lumea exterioară este un cuvânt cheie pe care îl vom folosi pentru a ne referi la orice resursă pe care o are aplicația dvs. (de exemplu, magazinul Redux) care poate fi transformată pentru a satisface elementele de recuzită ale componentei dvs. naturale.

Scopul acestui articol: Cum putem păstra componentele „naturale” fără a le polua cu junk din lumea exterioară? De ce este mai bine?

Notă: Deși inspirată de terminologia lui Abramov și React Redux a lui Dan, definiția noastră a „containerelor” depășește puțin acest lucru și este subtil diferită. Singura diferență între containerul lui Dan Abramov și al nostru este doar la nivel conceptual. Dan spune că există două tipuri de componente: componente de prezentare și componente pentru containere. Facem acest lucru cu un pas mai departe și spunem că există componente și apoi containere. Chiar dacă implementăm containere cu componente, nu ne gândim la containere ca la componente la nivel conceptual. De aceea, vă recomandăm să introduceți containerul în indexfișier - deoarece este o punte între componenta dvs. naturală și lumea exterioară și nu stă pe cont propriu.

Deși acest articol se concentrează pe componente, containerele ocupă cea mai mare parte a acestui articol.

De ce?

Realizarea componentelor naturale - Ușor, chiar distractiv.

Conectarea componentelor dvs. la lumea exterioară - Un pic mai greu.

După cum o văd, există trei motive majore pentru care ți-ai polua componenta naturală cu junk din lumea exterioară:

  1. Structuri de date ciudate
  2. Cerințe în afara sferei componentei (cum ar fi exemplul de mai sus)
  3. Lansarea evenimentelor la actualizări sau la montare

Următoarele secțiuni vor încerca să acopere aceste situații cu exemple cu diferite tipuri de implementări de containere.

Lucrul cu structuri de date ciudate

Uneori, pentru a reda informațiile solicitate, trebuie să legați datele împreună și să le transformați în ceva mai sensibil. Din lipsa unui cuvânt mai bun, structurile de date „ciudate” sunt pur și simplu structuri de date care sunt nenaturale pentru componenta dvs. de utilizat.

Este foarte tentant să trimiți structuri de date ciudate direct într-o componentă și să faci transformarea în interiorul componentei în sine, dar acest lucru duce la componente confuze și adesea greu de testat.

M-am surprins căzând în această capcană recent, când am primit sarcina de a crea o componentă care a obținut datele dintr-o anumită structură de date pe care o folosim pentru a susține un anumit tip de formular.

ChipField.propTypes = { field: PropTypes.object.isRequired, // <-- the "weird" data structure onEditField: PropTypes.func.isRequired, // <-- and a weird event too };

Componenta a luat în această fieldstructură de date ciudată ca un element de sprijin. Din punct de vedere practic, s-ar putea să fie bine dacă nu ar fi trebuit să atingem acest lucru din nou, dar a devenit o problemă reală atunci când ni s-a cerut să îl folosim din nou într-un loc diferit care nu are legătură cu această structură de date.

Deoarece componenta a necesitat această structură de date, a fost imposibil să o reutilizăm și a fost confuză refacerea. Testele pe care le-am scris inițial au fost, de asemenea, confuze, deoarece au batjocorit această structură de date ciudată. Am întâmpinat probleme în înțelegerea testelor și probleme în rescrierea lor când am refactorizat în cele din urmă.

Din păcate, structurile de date ciudate sunt inevitabile, dar utilizarea containerelor este o modalitate excelentă de a face față acestora. O soluție aici este că arhitecturarea componentelor dvs. în acest mod vă oferă opțiunea de a extrage și a gradua componenta într-una reutilizabilă. Dacă treceți o structură de date ciudată într-o componentă, pierdeți acea opțiune.

Notă: nu sugerez ca toate componentele pe care le realizați să fie generice de la început. Sugestia este să vă gândiți la ceea ce face componenta dvs. la un nivel fundamental și apoi să eliminați decalajul. În consecință, este mai probabil să aveți opțiunea de a vă transforma componenta într-una reutilizabilă, cu o muncă minimă.

Implementarea containerelor folosind componente funcționale

Dacă mapați strict recuzită, o opțiune simplă de implementare este să utilizați o altă componentă funcțională:

import React from 'react'; import PropTypes from 'prop-types'; import getValuesFromField from './helpers/getValuesFromField'; import transformValuesToField from './helpers/transformValuesToField'; import ChipField from './ChipField'; export default function ChipFieldContainer({ field, onEditField }) { const values = getValuesFromField(field); function handleOnChange(values) { onEditField(transformValuesToField(values)); } return ; } // external props ChipFieldContainer.propTypes = { field: PropTypes.object.isRequired, onEditField: PropTypes.func.isRequired, };

Și structura de dosare pentru o componentă de acest gen arată ceva de genul:

/ChipField -ChipField.js ------------------ the "natural" chip field -ChipField.test.js -index.js ---------------------- the "container" -index.test.js /helpers ----------------------- a folder for the helpers/utils -getValuesFromField.js -getValuesFromField.test.js -transformValuesToField.js -transformValuesToField.test.js

S-ar putea să vă gândiți „asta e prea mult de lucru” - și dacă sunteți, atunci îl înțeleg. Se poate părea că există mai mult de lucru aici, deoarece există mai multe fișiere și un pic de indirectare, dar iată partea care vă lipsește:

import { connect } from 'react-redux'; import getPictureUrl from './helpers/getPictureUrl'; import PersonCard from './PersonCard'; const mapStateToProps = (state, ownProps) => { const { person } = ownProps; const { name, jobTitle, customPictureUrl, officialPictureUrl } = person; const { preferOfficial } = state.settings; const pictureUrl = getPictureUrl(preferOfficial, customPictureUrl, officialPictureUrl); return { name, jobTitle, pictureUrl }; }; const mapDispatchToProps = null; export default connect( mapStateToProps, mapDispatchToProps, )(PersonCard);

It’s still the same amount of work regardless if you transformed data outside of the component or inside the component. The difference is, when you transform data outside of the component, you’re giving yourself a more explicit spot to test that your transformations are correct while also separating concerns.

Fulfilling requirements outside of the scope of the component

Like the Person Card example above, it’s very likely that when you adopt this “golden rule” of thinking, you’ll realize that certain requirements are outside the scope of the actual component. So how do you fulfill those?

You guessed it: Containers ?

You can create containers that do a little bit of extra work to keep your component natural. When you do this, you end up with a more focused component that is much simpler and a container that is better tested.

Let’s implement a PersonCard container to illustrate the example.

Implementing containers using higher order components

React Redux uses higher order components to implement containers that push and map props from the Redux store. Since we got this terminology from React Redux, it comes with no surprise that React Redux’s connect is a container.

Regardless if you’re using a function component to map props, or if you’re using higher order components to connect to the Redux store, the golden rule and the job of the container are still the same. First, write your natural component and then use the higher order component to bridge the gap.

Folder structure for above:

/PersonCard -PersonCard.js ----------------- natural component -PersonCard.test.js -index.js ---------------------- container -index.test.js /helpers -getPictureUrl.js ------------ helper -getPictureUrl.test.js
Note: In this case, it wouldn’t be too practical to have a helper for getPictureUrl. This logic was separated simply to show that you can. You also might’ve noticed that there is no difference in folder structure regardless of container implementation.

If you’ve used Redux before, the example above is something you’re probably already familiar with. Again, this golden rule isn’t necessarily a new idea but a subtle new way of thinking.

Additionally, when you implement containers with higher order components, you also have the ability to functionally compose higher order components together — passing props from one higher order component to the next. Historically, we’ve chained multiple higher order components together to implement a single container.

2019 Note: The React community seems to be moving away from higher order components as a pattern. I would also recommend the same. My experience when working with these is that they can be confusing for team members who aren’t familiar with functional composition and they can cause what is known as “wrapper hell” where components are wrapped too many times causing significant performance issues. Here are some related articles and resources on this: Hooks talk (2018) Recompose talk (2016) , Use a Render Prop! (2017), When to NOT use Render Props (2018).

You promised me hooks

Implementing containers using hooks

Why are hooks featured in this article? Because implementing containers becomes a lot easier with hooks.

If you’re not familiar with React hooks, then I would recommend watching Dan Abramov’s and Ryan Florence’s talks introducing the concept during React Conf 2018.

The gist is that hooks are the React team’s response to the issues with higher order components and similar patterns. React hooks are intended to be a superior replacement pattern for both in most cases.

This means that implementing containers can be done with a function component and hooks ?

In the example below, we’re using the hooks useRoute and useRedux to represent the “outside world” and we’re using the helper getValues to map the outside world into props usable by your natural component. We’re also using the helper transformValues to transform your component’s output to the outside world represented by dispatch.

import React from 'react'; import PropTypes from 'prop-types'; import { useRouter } from 'react-router'; import { useRedux } from 'react-redux'; import actionCreator from 'your-redux-stuff'; import getValues from './helpers/getVaules'; import transformValues from './helpers/transformValues'; import FooComponent from './FooComponent'; export default function FooComponentContainer(props) { // hooks const { match } = useRouter({ path: /* ... */ }); // NOTE: `useRedux` does not exist yet and probably won't look like this const { state, dispatch } = useRedux(); // mapping const props = getValues(state, match); function handleChange(e) { const transformed = transformValues(e); dispatch(actionCreator(transformed)); } // natural component return ; } FooComponentContainer.propTypes = { /* ... */ };

And here’s the reference folder structure:

/FooComponent ----------- the whole component for others to import -FooComponent.js ------ the "natural" part of the component -FooComponent.test.js -index.js ------------- the "container" that bridges the gap -index.js.test.js and provides dependencies /helpers -------------- isolated helpers that you can test easily -getValues.js -getValues.test.js -transformValues.js -transformValues.test.js

Firing events in containers

The last type of scenario where I find myself diverging from a natural component is when I need to fire events related to changing props or mounting components.

For example, let’s say you’re tasked with making a dashboard. The design team hands you a mockup of the dashboard and you transform that into a React component. You’re now at the point where you have to populate this dashboard with data.

You notice that you need to call a function (e.g. dispatch(fetchAction)) when your component mount in order for that to happen.

In scenarios like this, I found myself adding componentDidMount and componentDidUpdate lifecycle methods and adding onMount or onDashboardIdChanged props because I needed some event to fire in order to link my component to the outside world.

Following the golden rule, these onMount and onDashboardIdChanged props are unnatural and therefore should live in the container.

The nice thing about hooks is that it makes dispatching events onMount or on prop change much simpler!

Firing events on mount:

To fire an event on mount, call useEffect with an empty array.

import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import { useRedux } from 'react-redux'; import fetchSomething_reduxAction from 'your-redux-stuff'; import getValues from './helpers/getVaules'; import FooComponent from './FooComponent'; export default function FooComponentContainer(props) { // hooks // NOTE: `useRedux` does not exist yet and probably won't look like this const { state, dispatch } = useRedux(); // dispatch action onMount useEffect(() => { dispatch(fetchSomething_reduxAction); }, []); // the empty array tells react to only fire on mount // //reactjs.org/docs/hooks-effect.html#tip-optimizing-performance-by-skipping-effects // mapping const props = getValues(state, match); // natural component return ; } FooComponentContainer.propTypes = { /* ... */ }; 

Firing events on prop changes:

useEffect has the ability to watch your property between re-renders and calls the function you give it when the property changes.

Before useEffect I found myself adding unnatural lifecycle methods and onPropertyChanged props because I didn’t have a way to do the property diffing outside the component:

import React from 'react'; import PropTypes from 'prop-types'; /** * Before `useEffect`, I found myself adding "unnatural" props * to my components that only fired events when the props diffed. * * I'd find that the component's `render` didn't even use `id` * most of the time */ export default class BeforeUseEffect extends React.Component { static propTypes = { id: PropTypes.string.isRequired, onIdChange: PropTypes.func.isRequired, }; componentDidMount() { this.props.onIdChange(this.props.id); } componentDidUpdate(prevProps) { if (prevProps.id !== this.props.id) { this.props.onIdChange(this.props.id); } } render() { return // ... } }

Now with useEffect there is a very lightweight way to fire on prop changes and our actual component doesn’t have to add props that are unnecessary to its function.

import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import { useRedux } from 'react-redux'; import fetchSomething_reduxAction from 'your-redux-stuff'; import getValues from './helpers/getVaules'; import FooComponent from './FooComponent'; export default function FooComponentContainer({ id }) { // hooks // NOTE: `useRedux` does not exist yet and probably won't look like this const { state, dispatch } = useRedux(); // dispatch action onMount useEffect(() => { dispatch(fetchSomething_reduxAction); }, [id]); // `useEffect` will watch this `id` prop and fire the effect when it differs // //reactjs.org/docs/hooks-effect.html#tip-optimizing-performance-by-skipping-effects // mapping const props = getValues(state, match); // natural component return ; } FooComponentContainer.propTypes = { id: PropTypes.string.isRequired, }; 
Disclaimer: before useEffect there were ways of doing prop diffing inside a container using other higher order components (like recompose’s lifecycle) or creating a lifecycle component like react router does internally, but these ways were either confusing to the team or were unconventional.

What are the benefits here?

Components stay fun

For me, creating components is the most fun and satisfying part of front-end development. You get to turn your team’s ideas and dreams into real experiences and that’s a good feeling I think we all relate to and share.

There will never be a scenario where your component’s API and experience is ruined by the “outside world”. Your component gets to be what you imagined it without extra props — that’s my favorite benefit of this golden rule.

More opportunities to test and reuse

When you adopt an architecture like this, you’re essentially bringing a new data-y layer to the surface. In this “layer” you can switch gears where you’re more concerned about the correctness of data going into your component vs. how your component works.

Whether you’re aware of it or not, this layer already exists in your app but it may be coupled with presentational logic. What I’ve found is that when I surface this layer, I can make a lot of code optimizations and reuse a lot of logic that I would’ve otherwise rewritten without knowing the commonalities.

I think this will become even more obvious with the addition of custom hooks. Custom hooks gives us a much simpler way to extract logic and subscribe to external changes — something that a helper function could not do.

Maximize team throughput

When working on a team, you can separate the development of containers and components. If you agree on APIs beforehand, you can concurrently work on:

  1. Web API (i.e. back-end)
  2. Fetching data from the web API (or similar) and transforming the data to the component’s APIs
  3. The components

Are there any exceptions?

Much like the real Golden Rule, this golden rule is also a golden rule of thumb. There are some scenarios where it makes sense to write a seemingly unnatural component API to reduce the complexity of some transformations.

A simple example would the names of props. It would make things more complicated if engineers renamed data keys under the argument that it’s more “natural”.

It’s definitely possible to take this idea too far where you end up overgeneralizing too soon, and that can also be a trap.

The bottom line

More or less, this “golden rule” is simply re-hashing the existing idea of presentational components vs. container components in a new light. If you evaluate what your component needs on a fundamental level then you’ll probably end up with simpler and more readable parts.

Thank you!