Hogyan segíthet a React összetevők „aranyszabálya” a jobb kódírásban

És hogyan jönnek létre a horgok

Nemrégiben elfogadtam egy új filozófiát, amely megváltoztatja az alkatrészek gyártásának módját. Ez nem feltétlenül új ötlet, inkább finom új gondolkodásmód.

Az alkatrészek aranyszabálya

Hozzon létre és definiáljon összetevőket a legtermészetesebb módon, kizárólag annak mérlegelésére, hogy mire van szükségük a működéshez.

Ismét finom kijelentés, és azt gondolhatja, hogy már követi, de könnyű ellenkezni ezzel.

Tegyük fel például, hogy a következő összetevővel rendelkezik:

Ha ezt a komponenst „természetesen” definiálja, akkor valószínűleg a következő API-val írja:

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

Ami meglehetősen egyszerű - kizárólag annak megnézésére, hogy mire van szüksége a működéséhez, csak névre, munkakörre és kép URL-re van szüksége.

De tegyük fel, hogy a felhasználói beállításoktól függően megkövetel egy „hivatalos” képet. Kísértésbe eshet egy ilyen API megírása:

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

Úgy tűnhet, hogy az alkatrész működéséhez ezekre az extra kellékekre van szükség, de valójában a komponens nem néz ki másként, és működéséhez nem kellenek ezek a kiegészítő kellékek. Amit ezek az extra kellékek tesznek, az az, preferOfficialhogy ezt a beállítást összekapcsolja az Ön összetevőjével, és a komponens bármilyen, a kontextuson kívüli felhasználását valóban természetellenesnek érzi.

A szakadék áthidalása

Tehát ha a kép URL váltásának logikája nem magában az összetevőben található, akkor hova tartozik?

Mit szólnál egy indexfájlhoz?

Elfogadtunk egy mappastruktúrát, ahol minden komponens egy saját nevű mappába kerül, ahol a indexfájl felelős a „természetes” komponens és a külvilág közötti szakadék áthidalásáért. Ezt a fájlt „konténernek” nevezzük (a React Redux „konténer” alkatrészeinek koncepciója ihlette).

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

A tárolókat olyan kódrészletként definiáljuk , amely áthidalja a természetes alkatrész és a külvilág közötti szakadékot. Emiatt ezeket a dolgokat néha „injektoroknak” is hívjuk.

A természetes összetevője a kód azt létrehozni, ha arra csak akkor látható egy kép, amit kellett make (anélkül, hogy a részleteket kerültél azt kap adatokat, vagy ha lenne elhelyezni a app - minden, amit tudni az, hogy működnie kell).

A külvilág egy olyan kulcsszó, amelyet az alkalmazásod bármely erőforrására (pl. A Redux áruház) utalunk, amelyet átalakíthatunk, hogy kielégítsük a természetes alkatrészek kellékeit.

A cikk célja: Hogyan tarthatjuk az alkatrészeket „természetesnek” anélkül, hogy szennyeznénk őket a külvilág szeméttel? Miért jobb ez?

Megjegyzés: Bár Dan Abramov és React Redux terminológiája ihlette, a „konténerek” definíciónk kissé meghaladja ezt és finoman eltér. Az egyetlen különbség Dan Abramov konténere és a miénk között csak fogalmi szinten van. Dan szerint kétféle komponens létezik: bemutató és konténer alkatrészek. Tesszük ezt egy lépéssel tovább, és azt mondjuk, hogy vannak alkatrészek, majd konténerek. Annak ellenére, hogy a konténereket alkatrészekkel valósítjuk meg, koncepciós szempontból nem gondolunk a konténerekre mint alkatrészekre. Ezért javasoljuk, hogy helyezze el a konténerét a indexfájlban - mert ez híd a természetes komponense és a külvilág között, és nem áll önmagában.

Bár ez a cikk az alkatrészekre összpontosít, a cikk nagy részét a konténerek veszik igénybe.

Miért?

Természetes alkatrészek készítése - Könnyű, még szórakoztató is.

Összetevők összekapcsolása a külvilággal - Kicsit nehezebb.

Ahogy látom, három fő oka van annak, hogy természetes összetevődet szennyezd a külvilágtól származó szeméttel:

  1. Furcsa adatszerkezetek
  2. Az összetevő hatókörén kívül eső követelmények (például a fenti példa)
  3. Események indítása frissítéseken vagy a mounton

A következő néhány szakasz megpróbálja ezeket a helyzeteket különböző típusú tároló-megvalósításokkal példákkal lefedni.

Fura adatstruktúrákkal való munka

Előfordul, hogy a szükséges információk megjelenítéséhez össze kell kapcsolnia az adatokat, és átalakítania kell ésszerűbbé. Jobb szó híján a „furcsa” adatstruktúrák egyszerűen olyan adatstruktúrák, amelyek természetellenesek az összetevő számára.

Nagyon csábító furcsa adatszerkezeteket közvetlenül átadni egy alkatrésznek, és az átalakítást magában a komponensben kell végrehajtani, de ez zavaros és gyakran nehezen tesztelhető összetevőket eredményez.

Nemrég fogtam el, hogy ebbe a csapdába esem, amikor feladatot kaptam, hogy hozzon létre egy összetevőt, amely egy adott adatstruktúrából kapta az adatait, amelyet egy adott típusú űrlap támogatásához használunk.

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

A komponens ezt a furcsa fieldadatstruktúrát támasztotta meg. A gyakorlatban ez rendben lehet, ha soha többé nem kell hozzányúlnunk a dologhoz, de valódi kérdéssé vált, amikor arra kérték, hogy használjuk újra egy másik helyen, amely nem kapcsolódik ehhez az adatstruktúrához.

Mivel a komponensnek szüksége volt erre az adatstruktúrára, lehetetlen volt újrafelhasználni, és zavaró volt a refaktor. Az eredetileg írt tesztek szintén zavaróak voltak, mert csúfolták ezt a furcsa adatszerkezetet. Problémáink voltak a tesztek megértésével, és gondjaink voltak azok újjáírásával, amikor végül átalakítottuk.

Sajnos a furcsa adatstruktúrák elkerülhetetlenek, de a konténerek használata remek módszer a kezelésükre. Az egyik elvonás az, hogy az alkatrészeket ilyen módon építve lehetőséget ad arra, hogy az összetevőt újrahasználhatóvá váljanak és osztják fel. Ha furcsa adatszerkezetet ad át egy összetevőnek, elveszíti ezt a lehetőséget.

Megjegyzés: Nem javaslom, hogy az összes gyártott alkatrész kezdettől fogva általános legyen. A javaslat az, hogy gondolkodjon el azon, hogy a komponense mit csinál alapvető szinten, majd áthidalja a szakadékot. Ennek eredményeként valószínűbb, hogy lehetősége van minimális munkával újrafelhasználhatóvá válni.

Konténerek megvalósítása funkciókomponensekkel

Ha szigorúan feltérképezi a kellékeket, akkor egy egyszerű megvalósítási lehetőség egy másik funkciókomponens használata:

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, };

Az ilyen összetevők mappaszerkezete pedig a következőképpen néz ki:

/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

Lehet, hogy arra gondolsz, hogy „ez túl sok munka” - és ha igen, akkor megértem. Úgy tűnhet, hogy itt még több a munka, mivel több fájl van és egy kis indirekt, de itt hiányzik:

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!