Wie die „Goldene Regel“ der React-Komponenten Ihnen helfen kann, besseren Code zu schreiben

Und wie Haken ins Spiel kommen

Kürzlich habe ich eine neue Philosophie eingeführt, die die Art und Weise, wie ich Komponenten herstelle, verändert. Es ist nicht unbedingt eine neue Idee, sondern eine subtile neue Denkweise.

Die goldene Regel der Komponenten

Erstellen und definieren Sie Komponenten auf natürlichste Weise, und berücksichtigen Sie dabei ausschließlich, was sie zum Funktionieren benötigen.

Auch hier handelt es sich um eine subtile Aussage, und Sie denken vielleicht, dass Sie ihr bereits folgen, aber es ist einfach, dagegen vorzugehen.

Angenommen, Sie haben die folgende Komponente:

Wenn Sie diese Komponente "natürlich" definieren würden, würden Sie sie wahrscheinlich mit der folgenden API schreiben:

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

Das ist ziemlich einfach - wenn Sie sich nur ansehen, was es braucht, um zu funktionieren, brauchen Sie nur einen Namen, eine Berufsbezeichnung und eine Bild-URL.

Angenommen, Sie müssen abhängig von den Benutzereinstellungen ein „offizielles“ Bild anzeigen. Sie könnten versucht sein, eine API wie folgt zu schreiben:

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

Es mag so aussehen, als ob die Komponente diese zusätzlichen Requisiten benötigt, um zu funktionieren, aber in Wirklichkeit sieht die Komponente nicht anders aus und benötigt diese zusätzlichen Requisiten nicht, um zu funktionieren. Diese zusätzlichen Requisiten koppeln diese preferOfficialEinstellung mit Ihrer Komponente und machen die Verwendung der Komponente außerhalb dieses Kontexts wirklich unnatürlich.

Schließung der Lücke

Wenn die Logik zum Umschalten der Bild-URL nicht in die Komponente selbst gehört, wo gehört sie dann hin?

Wie wäre es mit einer indexDatei?

Wir haben eine Ordnerstruktur eingeführt, in der jede Komponente in einen selbstbetitelten Ordner verschoben wird, in dem die indexDatei für die Überbrückung der Lücke zwischen Ihrer „natürlichen“ Komponente und der Außenwelt verantwortlich ist. Wir nennen diese Datei den „Container“ (inspiriert von React Redux 'Konzept der „Container“ -Komponenten).

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

Wir definieren Container als den Code, der diese Lücke zwischen Ihrer natürlichen Komponente und der Außenwelt schließt. Aus diesem Grund nennen wir diese Dinge manchmal auch „Injektoren“.

Ihre natürliche Komponente ist der Code, den Sie erstellen würden, wenn Ihnen nur ein Bild von dem angezeigt würde, was Sie machen mussten (ohne die Details darüber, wie Sie Daten erhalten würden oder wo sie in der App platziert würden - alles, was Sie wissen ist, dass es funktionieren sollte).

Die Außenwelt ist ein Schlüsselwort, mit dem wir auf alle Ressourcen Ihrer App verweisen (z. B. den Redux-Store), die transformiert werden können, um die Requisiten Ihrer natürlichen Komponente zu erfüllen.

Ziel für diesen Artikel: Wie können wir Komponenten „natürlich“ halten, ohne sie mit Müll von außen zu verschmutzen? Warum ist das besser?

Hinweis: Obwohl unsere Definition von „Containern“ von Dans Abramov- und React Redux-Terminologie inspiriert ist, geht sie etwas darüber hinaus und unterscheidet sich geringfügig. Der einzige Unterschied zwischen Dan Abramovs und unserem Container besteht nur auf konzeptioneller Ebene. Laut Dan gibt es zwei Arten von Komponenten: Präsentationskomponenten und Containerkomponenten. Wir gehen noch einen Schritt weiter und sagen, es gibt Komponenten und dann Container. Obwohl wir Container mit Komponenten implementieren, betrachten wir Container nicht als Komponenten auf konzeptioneller Ebene. Aus diesem Grund empfehlen wir, Ihren Container in die indexDatei aufzunehmen - da er eine Brücke zwischen Ihrer natürlichen Komponente und der Außenwelt darstellt und nicht für sich allein steht.

Obwohl sich dieser Artikel auf Komponenten konzentriert, machen Container den größten Teil dieses Artikels aus.

Warum?

Natürliche Komponenten herstellen - Einfach, macht sogar Spaß.

Verbinden Sie Ihre Komponenten mit der Außenwelt - etwas schwieriger.

So wie ich das sehe, gibt es drei Hauptgründe, warum Sie Ihre natürliche Komponente mit Müll von außen verschmutzen würden:

  1. Seltsame Datenstrukturen
  2. Anforderungen außerhalb des Geltungsbereichs der Komponente (wie im obigen Beispiel)
  3. Auslösen von Ereignissen bei Updates oder beim Mounten

In den nächsten Abschnitten wird versucht, diese Situationen anhand von Beispielen mit verschiedenen Arten von Containerimplementierungen zu behandeln.

Arbeiten mit seltsamen Datenstrukturen

Manchmal müssen Sie Daten verknüpfen und in etwas Sinnvolleres umwandeln, um die erforderlichen Informationen zu rendern. Mangels eines besseren Wortes sind „seltsame“ Datenstrukturen einfach Datenstrukturen, die für Ihre Komponente unnatürlich sind.

Es ist sehr verlockend, seltsame Datenstrukturen direkt in eine Komponente zu übergeben und die Transformation innerhalb der Komponente selbst durchzuführen. Dies führt jedoch zu verwirrenden und oft schwer zu testenden Komponenten.

Ich bin kürzlich in diese Falle geraten, als ich beauftragt wurde, eine Komponente zu erstellen, deren Daten aus einer bestimmten Datenstruktur stammen, die wir zur Unterstützung eines bestimmten Formulartyps verwenden.

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

Die Komponente nahm diese seltsame fieldDatenstruktur als Requisite auf. In der Praxis wäre dies vielleicht in Ordnung gewesen, wenn wir das Ding nie wieder anfassen müssten, aber es wurde zu einem echten Problem, als wir gebeten wurden, es an einer anderen Stelle erneut zu verwenden, die nichts mit dieser Datenstruktur zu tun hat.

Da die Komponente diese Datenstruktur benötigte, war es unmöglich, sie wiederzuverwenden, und die Umgestaltung war verwirrend. Die Tests, die wir ursprünglich geschrieben haben, waren ebenfalls verwirrend, weil sie diese seltsame Datenstruktur verspotteten. Wir hatten Probleme, die Tests zu verstehen und sie neu zu schreiben, als wir sie schließlich überarbeiteten.

Leider sind seltsame Datenstrukturen unvermeidlich, aber die Verwendung von Containern ist eine großartige Möglichkeit, mit ihnen umzugehen. Ein Vorteil dabei ist, dass Sie durch die Architektur Ihrer Komponenten auf diese Weise die Möglichkeit haben, die Komponente zu extrahieren und in eine wiederverwendbare zu verwandeln. Wenn Sie eine seltsame Datenstruktur an eine Komponente übergeben, verlieren Sie diese Option.

Hinweis: Ich schlage nicht vor, dass alle von Ihnen erstellten Komponenten von Anfang an generisch sein sollten. Der Vorschlag ist, darüber nachzudenken, was Ihre Komponente auf einer fundamentalen Ebene tut, und dann die Lücke zu schließen. Infolgedessen haben Sie mit größerer Wahrscheinlichkeit die Möglichkeit , Ihre Komponente mit minimalem Arbeitsaufwand in eine wiederverwendbare Komponente umzuwandeln.

Container mit Funktionskomponenten implementieren

Wenn Sie Requisiten streng zuordnen, besteht eine einfache Implementierungsoption darin, eine andere Funktionskomponente zu verwenden:

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

Und die Ordnerstruktur für eine Komponente wie diese sieht ungefähr so ​​aus:

/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

Sie denken vielleicht "das ist zu viel Arbeit" - und wenn Sie es sind, dann verstehe ich es. Es mag so aussehen, als ob hier mehr Arbeit zu erledigen ist, da es mehr Dateien und ein bisschen Indirektion gibt, aber hier ist der Teil, den Sie vermissen:

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!