Як “Золоте правило” компонентів React може допомогти вам написати кращий код

І як гачки вступають у гру

Нещодавно я прийняв нову філософію, яка змінює спосіб виготовлення компонентів. Це не обов’язково нова ідея, а скоріше тонкий новий спосіб мислення.

Золоте правило компонентів

Створюйте та визначайте компоненти найбільш природним чином, винятково враховуючи, що їм потрібно для функціонування.

Знову ж таки, це тонка заява, і ви можете подумати, що вже дотримуєтесь її, але легко піти проти цього.

Наприклад, припустимо, у вас є такий компонент:

Якби ви визначали цей компонент "природним шляхом", то, напевно, написали б його за допомогою наступного API:

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

Що досить просто - виключно, дивлячись на те, що йому потрібно для функціонування, вам просто потрібно ім’я, назва посади та URL-адреса зображення.

Але припустимо, ви маєте вимогу показати „офіційне” зображення залежно від налаштувань користувача. У вас може виникнути спокуса написати API приблизно так:

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

Може здатися, що компоненту потрібні ці додаткові реквізити для функціонування, але насправді компонент не виглядає інакше і йому не потрібні ці додаткові реквізити для роботи. Те, що роблять ці додаткові реквізити, це поєднання цього preferOfficialпараметра з вашим компонентом і робить будь-яке використання компонента поза цим контекстом справді неприродним.

Усунення розриву

Отже, якщо логіка перемикання URL-адреси зображення не належить самому компоненту, куди вона належить?

Як щодо indexфайлу?

Ми прийняли структуру папок, де кожен компонент переходить у однойменну папку, де indexфайл відповідає за подолання розриву між вашим “природним” компонентом та зовнішнім світом. Ми називаємо цей файл "контейнером" (натхненним концепцією React Redux про "компоненти контейнера").

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

Ми визначаємо контейнери як фрагмент коду, який заповнює розрив між вашим природним компонентом та зовнішнім світом. З цієї причини ми також іноді називаємо ці речі “інжекторами”.

Ваш природний компонент - це код, який ви створили б, якщо б вам показали лише зображення того, що вам потрібно зробити (без детальної інформації про те, як ви отримаєте дані або де вони будуть розміщені в додатку - все, що ви знаєте полягає в тому, що він повинен функціонувати).

Зовнішній світ є ключовим словом , ми будемо використовувати для позначення будь-якого ресурсу додаток має (наприклад , в магазині Redux) , які можуть бути перетворені , щоб задовольнити реквізиту вашого природного компонента.

Ціль цієї статті: Як ми можемо зберегти компоненти «природними», не забруднюючи їх мотлохом із зовнішнього світу? Чому це краще?

Примітка: Незважаючи на те, що натхненне термінологією Данова Абрамова та React Redux, наше визначення поняття "контейнери" виходить трохи за межі цього і дещо відрізняється. Єдина різниця між контейнером Дана Абрамова та нашим лише на концептуальному рівні. Dan's каже, що існує два типи компонентів: презентаційні та контейнерні компоненти. Ми робимо цей крок далі і кажемо, що є компоненти, а потім контейнери. Незважаючи на те, що ми реалізуємо контейнери з компонентами, ми не розглядаємо контейнери як компоненти на концептуальному рівні. Ось чому ми рекомендуємо помістити ваш контейнер у indexфайл - адже це міст між вашим природним компонентом та зовнішнім світом і не є самостійним.

Хоча ця стаття орієнтована на компоненти, контейнери займають основну частину цієї статті.

Чому?

Виготовлення натуральних компонентів - легко, навіть весело.

Підключення компонентів до зовнішнього світу - трохи складніше.

На мою думку, є три основні причини, через які ви забруднюєте свій природний компонент мотлохом із зовнішнього світу:

  1. Дивні структури даних
  2. Вимоги поза сферою дії компонента (як приклад вище)
  3. Стрілянина подій на оновленнях або на монтуванні

Наступні кілька розділів спробують висвітлити ці ситуації прикладами з різними типами реалізацій контейнерів.

Робота з дивними структурами даних

Іноді для того, щоб надати необхідну інформацію, потрібно зв’язати дані та перетворити їх на щось більш розумне. За відсутності кращого слова, «дивні» структури даних - це просто структури даних, неприродні для використання вашим компонентом.

Дуже спокусливо передати дивні структури даних безпосередньо в компонент і виконати перетворення всередині самого компонента, але це призводить до заплутаних і часто важких для тестування компонентів.

Нещодавно я потрапив у цю пастку, коли мені доручили створити компонент, який отримував дані з певної структури даних, яку ми використовуємо для підтримки певного типу форми.

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

Компонент сприйняв цю дивну fieldструктуру даних як опору. На практиці це могло б бути добре, якби нам більше ніколи не доводилось торкатися речі, але це стало справжньою проблемою, коли нас попросили використати її знову в іншому місці, не пов’язаному з цією структурою даних.

Оскільки компонент вимагав цієї структури даних, повторно використовувати її було неможливо, а рефакторингу було незрозуміло. Тести, які ми спочатку писали, також бентежили, оскільки висміювали цю дивну структуру даних. У нас були проблеми з розумінням тестів і проблема з їх переписанням, коли ми врешті реконструювали.

На жаль, дивних структур даних не уникнути, але використання контейнерів - це чудовий спосіб боротися з ними. Тут можна взяти один висновок: архітектура компонентів таким чином дає вам можливість витягувати та переводити компонент у багаторазовий. Якщо ви передасте дивну структуру даних у компонент, ви втратите цей варіант.

Примітка: Я не припускаю, що всі виготовлені вами компоненти повинні бути загальними з самого початку. Пропозиція полягає в тому, щоб подумати про те, що робить ваш компонент на фундаментальному рівні, а потім подолати розрив. Як наслідок, ви, швидше за все, матимете можливість перетворити свій компонент на багаторазовий із мінімальною роботою.

Реалізація контейнерів за допомогою функціональних компонентів

Якщо ви строго картографуєте реквізит, простим варіантом реалізації є використання іншого функціонального компонента:

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

А структура папок для такого компонента виглядає приблизно так:

/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

Можливо, ви думаєте, "це занадто багато роботи" - і якщо ви це робите, тоді я її отримую. Може здатися, що тут потрібно попрацювати більше, оскільки файлів більше і непрямо, але ось та частина, якої вам не вистачає:

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!