200 байт для управления состоянием React-компонентов

  • React-хуки: это все, что нужно для управления состоянием.
  • ~200 байт, min+gz.
  • Знакомый API: просто пользуйтесь React, как обычно.
  • Минимальный API: хватит пяти минут, чтобы разобраться.
  • Написан на TypeScript, чтобы обеспечить автоматический вывод типов.

Главный вопрос: чем этот пакет лучше, чем Redux? Ну...


  • Он меньше. Он в 40 раз меньше.
  • Он быстрее. Изолируйте проблемы производительности на уровне компонентов.
  • Он проще в изучении. Вам в любом случае нужно уметь пользоваться React-хуками и контекстом, они классные.
  • Он проще в интеграции. Подключайте по одному компоненту за раз, не ломая совместимости с другими React-библиотеками.
  • Он проще в тестировании. Тестировать отдельно редьюсеры — напрасная трата времени, упростите тестирование самих React-компонентов.
  • Он проще с точки зрения типизации. Написан так, чтобы максимально задействовать выведение типов.
  • Он минималистичный. Это просто React.

Пример кода


import React, { useState } from "react"
import { createContainer } from "unstated-next"
import { render } from "react-dom"

function useCounter() {
  let [count, setCount] = useState(0)
  let decrement = () => setCount(count - 1)
  let increment = () => setCount(count + 1)
  return { count, decrement, increment }
}

let Counter = createContainer(useCounter)

function CounterDisplay() {
  let counter = Counter.useContainer()
  return (
    <div>
      <button onClick={counter.decrement}>-</button>
      <span>{counter.count}</span>
      <button onClick={counter.increment}>+</button>
    </div>
  )
}

function App() {
  return (
    <Counter.Provider>
      <CounterDisplay />
      <CounterDisplay />
    </Counter.Provider>
  )
}

render(<App />, document.getElementById("root"))

Отношение к Unstated


Я (Jamie Kyle — прим. пер.) рассматриваю данную библиотеку как преемника Unstated. Я сделал Unstated, поскольку был убежден, что React и сам отлично справлялся с управлением состоянием, и ему не хватало только простого механизма для разделения общего состояния и логики. Поэтому я создал Unstated как "минимальное" решение для данной проблемы.


С появлением хуков React стал гораздо лучше в плане выделения общего состояния и логики. Настолько лучше, что, с моей точки зрения, Unstated стал излишней абстракцией.


ТЕМ НЕ МЕНЕЕ, я считаю, что многие разработчики слабо представляют, как разделять логику и общее состояние приложения с помощью React-хуков. Это может быть связано просто с недостаточным качеством документации и инерцией сообщества, но я полагаю, что четкий API как раз способен исправить этот недостаток.


Unstated Next и есть этот самый API. Вместо того, чтобы быть "Минимальным API для разделения общего состояния и логики в React", теперь он "Минимальный API для понимания, как разделять общее состояние и логику в React".


Мне очень нравится React, я хочу, чтобы React процветал. Я бы предпочел, чтобы сообщество отказалось от использования внешних библиотек для управления состоянием наподобие Redux, и начало наконец в полную силу использовать встроенные в React инструменты.


Если вместо того, чтобы использовать Unstated, вы будете просто использовать React — я буду это только приветствовать. Пишите об этом в своих блогах! Выступайте об этом на конференциях! Делитесь своими знаниями с сообществом.


Руководство по Unstated-next


Если вы пока не знакомы с React-хуками, рекомендую прервать чтение и ознакомиться с
прекрасной документацией на сайте React.


Итак, с помощью хуков вы можете написать что-нибудь вроде такого компонента:


function CounterDisplay() {
  let [count, setCount] = useState(0)
  let decrement = () => setCount(count - 1)
  let increment = () => setCount(count + 1)
  return (
    <div>
      <button onClick={decrement}>-</button>
      <p>You clicked {count} times</p>
      <button onClick={increment}>+</button>
    </div>
  )
}

Если логику компонента требуется использовать в нескольких местах, ее можно вынести
в отдельный кастомный хук:


function useCounter() {
  let [count, setCount] = useState(0)
  let decrement = () => setCount(count - 1)
  let increment = () => setCount(count + 1)
  return { count, decrement, increment }
}

function CounterDisplay() {
  let counter = useCounter()
  return (
    <div>
      <button onClick={counter.decrement}>-</button>
      <p>You clicked {counter.count} times</p>
      <button onClick={counter.increment}>+</button>
    </div>
  )
}

Но что делать, когда вам требуется общее состояние, а не только логика?
Здесь пригодится контекст:


function useCounter() {
  let [count, setCount] = useState(0)
  let decrement = () => setCount(count - 1)
  let increment = () => setCount(count + 1)
  return { count, decrement, increment }
}

let Counter = createContext(null)

function CounterDisplay() {
  let counter = useContext(Counter)
  return (
    <div>
      <button onClick={counter.decrement}>-</button>
      <p>You clicked {counter.count} times</p>
      <button onClick={counter.increment}>+</button>
    </div>
  )
}

function App() {
  let counter = useCounter()
  return (
    <Counter.Provider value={counter}>
      <CounterDisplay />
      <CounterDisplay />
    </Counter.Provider>
  )
}

Это замечательно и прекрасно; чем больше людей будет писать в таком стиле, тем лучше.


Однако стоит внести еще чуть больше структуры и ясности, чтобы API предельно четко выражал ваши намерения.


Для этого мы добавили функцию createContainer(), чтобы можно было рассматривать ваши кастомные хуки как "контейнеры", чтобы наш четкий и ясный API просто невозможно было использовать неправильно.


import { createContainer } from "unstated-next"

function useCounter() {
  let [count, setCount] = useState(0)
  let decrement = () => setCount(count - 1)
  let increment = () => setCount(count + 1)
  return { count, decrement, increment }
}

let Counter = createContainer(useCounter)

function CounterDisplay() {
  let counter = Counter.useContainer()
  return (
    <div>
      <button onClick={counter.decrement}>-</button>
      <p>You clicked {counter.count} times</p>
      <button onClick={counter.increment}>+</button>
    </div>
  )
}

function App() {
  return (
    <Counter.Provider>
      <CounterDisplay />
      <CounterDisplay />
    </Counter.Provider>
  )
}

Сравните текст компонента до и после наших изменений:


- import { createContext, useContext } from "react"
+ import { createContainer } from "unstated-next"

  function useCounter() {
    ...
  }

- let Counter = createContext(null)
+ let Counter = createContainer(useCounter)

  function CounterDisplay() {
-   let counter = useContext(Counter)
+   let counter = Counter.useContainer()
    return (
      <div>
        ...
      </div>
    )
  }

  function App() {
-   let counter = useCounter()
    return (
-     <Counter.Provider value={counter}>
+     <Counter.Provider>
        <CounterDisplay />
        <CounterDisplay />
      </Counter.Provider>
    )
  }

Если вы пишете на TypeScript (а если нет — настоятельно рекомендую ознакомиться с ним), вы ко всему прочему получаете более качественный вывод типов. Если ваш кастомный хук строго типизирован, вывод всех остальных типов сработает автоматически.


API


createContainer(useHook)


import { createContainer } from "unstated-next"

function useCustomHook() {
  let [value, setValue] = useState()
  let onChange = e => setValue(e.currentTarget.value)
  return { value, onChange }
}

let Container = createContainer(useCustomHook)
// Container === { Provider, useContainer }

<Container.Provider>


function ParentComponent() {
  return (
    <Container.Provider>
      <ChildComponent />
    </Container.Provider>
  )
}

Container.useContainer()


function ChildComponent() {
  let input = Container.useContainer()
  return <input value={input.value} onChange={input.onChange} />
}

useContainer(Container)


import { useContainer } from "unstated-next"

function ChildComponent() {
  let input = useContainer(Container)
  return <input value={input.value} onChange={input.onChange} />
}

Советы


Совет #1: Объединение контейнеров


Поскольку мы имеем дело с кастомными хуками, мы можем объединять контейнеры внутри других хуков.


function useCounter() {
  let [count, setCount] = useState(0)
  let decrement = () => setCount(count - 1)
  let increment = () => setCount(count + 1)
  return { count, decrement, increment, setCount }
}

let Counter = createContainer(useCounter)

function useResettableCounter() {
  let counter = Counter.useContainer()
  let reset = () => counter.setCount(0)
  return { ...counter, reset }
}

Совет #2: Используйте маленькие контейнеры


Контейнеры лучше всего делать маленькими и четко сфокусированными на конкретной задаче. Если вам нужна дополнительная бизнес-логика в контейнерах — выносите новые операции в отдельные хуки, а состояние пусть хранится в контейнерах.


function useCount() {
  return useState(0)
}

let Count = createContainer(useCount)

function useCounter() {
  let [count, setCount] = Count.useContainer()
  let decrement = () => setCount(count - 1)
  let increment = () => setCount(count + 1)
  let reset = () => setCount(0)
  return { count, decrement, increment, reset }
}

Совет #3: Оптимизация компонентов


Не существует никакой отдельной "оптимизации" для unstated-next, достаточно обычных приемов оптимизации React-компонентов.


1) Оптимизация тяжелых поддеревьев с помощью разбиения компонентов на части.


До:


function CounterDisplay() {
  let counter = Counter.useContainer()
  return (
    <div>
      <button onClick={counter.decrement}>-</button>
      <p>You clicked {counter.count} times</p>
      <button onClick={counter.increment}>+</button>
      <div>
        <div>
          <div>
            <div>СУПЕР НАВОРОЧЕННОЕ ПОДДЕРЕВО КОМПОНЕНТОВ</div>
          </div>
        </div>
      </div>
    </div>
  )
}

После:


function ExpensiveComponent() {
  return (
    <div>
      <div>
        <div>
          <div>СУПЕР НАВОРОЧЕННОЕ ПОДДЕРЕВО КОМПОНЕНТОВ</div>
        </div>
      </div>
    </div>
  )
}

function CounterDisplay() {
  let counter = Counter.useContainer()
  return (
    <div>
      <button onClick={counter.decrement}>-</button>
      <p>You clicked {counter.count} times</p>
      <button onClick={counter.increment}>+</button>
      <ExpensiveComponent />
    </div>
  )
}

2) Оптимизация тяжелых операций с помощью хука useMemo()


До:


function CounterDisplay(props) {
  let counter = Counter.useContainer()

  // Вычислять выражение каждый раз, когда обновляется `counter` — слишком медленно
  let expensiveValue = expensiveComputation(props.input)

  return (
    <div>
      <button onClick={counter.decrement}>-</button>
      <p>You clicked {counter.count} times</p>
      <button onClick={counter.increment}>+</button>
    </div>
  )
}

После:


function CounterDisplay(props) {
  let counter = Counter.useContainer()

  // Пересчитываем значение только тогда, когда входные данные изменились
  let expensiveValue = useMemo(() => {
    return expensiveComputation(props.input)
  }, [props.input])

  return (
    <div>
      <button onClick={counter.decrement}>-</button>
      <p>You clicked {counter.count} times</p>
      <button onClick={counter.increment}>+</button>
    </div>
  )
}

3) Снижаем количество повторных рендеров с помощью React.memo() and useCallback()


До:


function useCounter() {
  let [count, setCount] = useState(0)
  let decrement = () => setCount(count - 1)
  let increment = () => setCount(count + 1)
  return { count, decrement, increment }
}

let Counter = createContainer(useCounter)

function CounterDisplay(props) {
  let counter = Counter.useContainer()
  return (
    <div>
      <button onClick={counter.decrement}>-</button>
      <p>You clicked {counter.count} times</p>
      <button onClick={counter.increment}>+</button>
    </div>
  )
}

После:


function useCounter() {
  let [count, setCount] = useState(0)
  let decrement = useCallback(() => setCount(count - 1), [count])
  let increment = useCallback(() => setCount(count + 1), [count])
  return { count, decrement, increment }
}

let Counter = createContainer(useCounter)

let CounterDisplayInner = React.memo(props => {
  return (
    <div>
      <button onClick={props.decrement}>-</button>
      <p>You clicked {props.count} times</p>
      <button onClick={props.increment}>+</button>
    </div>
  )
})

function CounterDisplay(props) {
  let counter = Counter.useContainer()
  return <CounterDisplayInner {...counter} />
}

Миграция с unstated


Я нарочно публикую эту библиотеку как отдельный пакет, потому что весь API полностью новый. Поэтому вы можете параллельно установить оба пакета и мигрировать постепенно.


Поделитесь своими впечатлениями о переходе на unstated-next, потому что в течение нескольких следующих месяцев я планирую на базе этой информации сделать две вещи:


  • Убедиться, что unstated-next удовлетворяет все нужды пользователей unstated.
  • Удостовериться, что для unstated есть четкий и ясный процесс миграции на unstated-next.

Возможно, я добавлю какие-то API в старую или новую библиотеку, чтобы упростить жизнь разработчикам. Что касается unstated-next, я обещаю, что добавленные API будут минимальными, насколько это возможно, и я приложу все усилия, чтобы библиотека осталась маленькой.


В будущем, я, вероятно, перенесу код unstated-next обратно в unstated в качестве новой мажорной версии. unstated-next будет по-прежнему доступен, чтобы можно было параллельно пользоваться unstated@2 и unstated-next в одном проекте. Затем, когда вы закончите миграцию, вы сможете обновиться до версии unstated@3 и удалить unstated-next (разумеется, обновив все импорты… поиска и замены должно хватить).


Несмотря на кардинальную смену API, я надеюсь, что смогу обеспечить вам максимально простую миграцию, насколько это вообще возможно. Буду рад любым замечаниям о том, что можно было бы сделать лучше.


Ссылки


Комментарии (8)


  1. JustDont
    13.05.2019 00:25
    +2

    Главный вопрос: чем этот пакет лучше, чем Redux?

    Назначить в противники вещь с известным заметным шлейфом проблем и победоносно закатать её в асфальт — отличный риторический приём. Всегда работает. Даже на технарей.

    Главный вопрос: а зачем этот пакет сравнивать именно с Redux? Это выглядит сравнением настоящего слона и фарфоровой статуэтки слона. И то и другое можно назвать «слон», но на этом сходства заканчиваются.

    3) Снижаем количество повторных рендеров с помощью React.memo() and useCallback()

    Каждый раз когда я читаю что-нибудь про state management и дохожу вот до таких вот примерчиков — меня начинает преследовать ощущение, что, простите меня за мой французский, меня где-то нае****.

    Потому что когда мне нужен state management — он мне нужен вот как раз для этих вещей — убрать страдания над повторными рендерами и прочим накатыванием стейта подальше из значимого кода. Желательно в библиотеку, этому как раз там очень хорошее место. Нафига мне «стейт менеджмент», если мне после этого самого менеджмента всё равно надо закатывать солнце вручную, если я хочу эффективного рендера? Ах, в 200 пожатых байт ничего такого не впихнуть? Ну а зачем мне тогда 200 пожатых байт, если всё что они дают — это тоненький слой сахара?


    1. yallie Автор
      13.05.2019 12:50

      Это выглядит сравнением настоящего слона и фарфоровой статуэтки слона.

      Имхо, автор ставит целью показать, что слон во многих проектах вообще не нужен.

      Каждый раз когда я читаю что-нибудь про state management и дохожу вот до таких вот примерчиков — меня начинает преследовать ощущение...

      Не могу не согласиться. Впрочем, автору плюс, что он этот примерчик сразу честно приводит.


  1. artalar
    13.05.2019 03:23

    Подобное решение — абсолютный вендор-лок реакта для приложения. Любой стейт менеджер — это, в первую очередь, способ сделать модель данных и работу с ней обособленно от view. Хотя кто сейчас умет это готовить…


    1. knotri
      13.05.2019 11:13

      А вы просто перестаньте считать реакт View, и отнеситесь к нему как к фреймворку.


      UPD: Решил пояснить свою мысль более подробно. Я не говорю что реакт это библиотека (хотя так считаю создатели самого реакта) как не говорю что он фреймворк.


      Я скорее о том что его МОЖНО использовать и как то, и как это. На моей практике реакт использовался ближе к понимаю "фреймворк", и это прекрасно работало. Реакт это же не только (props, state) => UI.


      Есть контекст, есть возможность использовать HOC для более сложной, чем UI логики. Есть кучу пакетов которые добавляют "фреймворкности" и которые неразрывно связаны с реактом, react-router например. Вы сами загоняете себя в рамки если всегда думаете в стиле "использую реакт только как View часть, чтоб можно было перейти". Вот в данной статье тоже самое, вам показали другой (и возможно более удобный для вашего проекта) стейт менеджер, а вы отказываетесь от него только по причине выше названой


      1. JustDont
        13.05.2019 11:35
        +1

        Но зачем, если там помимо View ничего интересного нет? Из-за useState считать реакт чем-то шире чистой отображаловки? Ха. Ха.

        Более того, реакт в качестве чистой отображаловки куда интереснее реакта для всего. Отображение — это очень сильная сторона в реакте, а всё остальное — на редкость неинтересное.


  1. ziart
    13.05.2019 09:43

    почему везде let?


    1. yallie Автор
      13.05.2019 12:52

      Думаю, просто стилистические предпочтения автора.
      Я не стал в примерах ничего менять на свой вкус, это же перевод.


    1. yallie Автор
      14.05.2019 11:16

      Вот тут он обосновывает свой выбор: jamie.build/const