Способов управления состоянием между компонентами в React множество. Из-за простоты автор остановился на React Context, но есть проблема. Если изменить значение одного поля, повторно будут отрисованы все компоненты, работающие с полями состояния.

Библиотека Teaful, которая в начале разработки называлась Fragmented store, решает эту проблему. Результат вы видите на КДПВ. Рассказываем о Teaful, пока начинается наш курс по Fullstack-разработке на Python.


Нефрагментированное состояние в React Context
Нефрагментированное состояние в React Context

Что такое fragmented-store

Fragmented-store позволяет использовать каждое поле store отдельно. Поскольку большинство компонентов использует несколько полей store, неинтересно, чтобы они заново рендерились при обновлении других полей.

Fragmented-store в React Context
Fragmented-store в React Context

Чтобы решить проблему с помощью React Context, создать контекст нужно для каждого поля store, а это сложно.

// ❌  Not recommended
<UsernameProvider>
  <AgeProvider>
    {children}
  </AgeProvider>
</UsernameProvider>

Когда полей много, чтобы избежать повторного рендеринга, каждому свойству необходим свой контекст, а значит, нужно написать слишком много логики. Однако теперь контекст можно создать автоматически. Поможет в этом простая и удобная библиотека на 500 байт — fragmented-store.

Создаём контекст и добавляем Provider

Инициализируем store данными, которые понадобятся вначале. Точно так же, как с контекстом React Context:

import createStore from "fragmented-store";

// It is advisable to set all the fields. If you don't know the 
// initial value you can set it to undefined or null to be able 
// to consume the values in the same way
const { Provider } = createStore({
  username: "Aral",
  age: 31,
});

function App() {
  return (
    <Provider>
     {/* rest */} 
    </Provider>
  );
}

Используем одно поле

Напишем 2 работающих с полем store компонента. Это похоже на useState в каждом компоненте с нужным свойством. Но здесь они вместе задействуют одно и то же свойство с одинаковым значением:

import createStore from "fragmented-store";

// We can import hooks with the property name in camelCase.
// username -> useUsername
// age -> useAge
const { Provider, useUsername, useAge } = createStore({
  username: "Aral",
  age: 31,
});

function App() {
  return (
    <Provider>
     <UsernameComponent />
     <AgeComponent /> 
    </Provider>
  );
}

// Consume the "username" field
function UsernameComponent() {
  const [username, setUsername] = useUsername();
  return (
    <button onClick={() => setUsername("AnotherUserName")}>
      Update {username}
    </button>
  );
}

// Consume the "age" field
function AgeComponent() {
  const [age, setAge] = useAge();
  return (
    <div>
      <div>{age}</div>
      <button onClick={() => setAge((s) => s + 1)}>Inc age</button>
    </div>
  );
}

Когда AgeComponent обновляет поле age, повторно отображается только AgeComponent, а UsernameComponent не использует ту же фрагментированную часть store и не отрисовывается.

Используем весь store

Что, если нужно обновить несколько полей? В таком случае нужен компонент, задействующий сразу весь store. Он повторно отрисуется для любого обновлённого поля:

import createStore from "fragmented-store";

// Special hook useStore
const { Provider, useStore } = createStore({
  username: "Aral",
  age: 31,
});

function App() {
  return (
    <Provider>
     <AllStoreComponent />
    </Provider>
  );
}

// Consume all fields of the store
function AllStoreComponent() {
  const [store, update] = useStore();

  console.log({ store }); // all store

  function onClick() {
    update({ age: 32, username: "Aral Roca" })
  }

  return (
    <button onClick={onClick}>Modify store</button>
  );
}

Если обновить только некоторые поля, то работающие с ними компоненты будут отрисованы, а работающие с другими полями — нет:

// It only updates the "username" field, other fields won't be updated
// The UsernameComponent is going to be re-rendered while AgeComponent won't :)
update({ username: "Aral Roca" }) 

Не нужно делать так, даже если есть возможность:

update(s => ({ ...s, username: "Aral" }))

Так повторно будут отрисованы только компоненты, которые для работы с полем username  используют хук useUsername.

Внутренняя реализация

Библиотека fragmented-store — это один очень короткий файл. Вы не пишете контексты React Context для каждого свойства вручную. Библиотека автоматически создаёт всё, что нужно для обновления хуков и другой работы с ними:

Пример
import React, { useState, useContext, createContext } from 'react'

export default function createStore(store = {}) {
  const keys = Object.keys(store)
  const capitalize = (k) => `${k[0].toUpperCase()}${k.slice(1, k.length)}`

  // storeUtils is the object we'll return with everything
  // (Provider, hooks)
  //
  // We initialize it by creating a context for each property and
  // returning a hook to consume the context of each property
  const storeUtils = keys.reduce((o, key) => {
    const context = createContext(store[key]) // Property context
    const keyCapitalized = capitalize(key)

    if (keyCapitalized === 'Store') {
      console.error(
        'Avoid to use the "store" name at the first level, it\'s reserved for the "useStore" hook.'
      )
    }

    return {
      ...o,
      // All contexts
      contexts: [...(o.contexts || []), { context, key }],
      // Hook to consume the property context
      [`use${keyCapitalized}`]: () => useContext(context),
    }
  }, {})

  // We create the main provider by wrapping all the providers
  storeUtils.Provider = ({ children }) => {
    const Empty = ({ children }) => children
    const Component = storeUtils.contexts
      .map(({ context, key }) => ({ children }) => {
        const ctx = useState(store[key])
        return <context.Provider value={ctx}>{children}</context.Provider>
      })
      .reduce(
        (RestProviders, Provider) =>
          ({ children }) =>
            (
              <Provider>
                <RestProviders>{children}</RestProviders>
              </Provider>
            ),
        Empty
      )

    return <Component>{children}</Component>
  }

  // As a bonus, we create the useStore hook to return all the
  // state. Also to return an updater that uses all the created hooks at
  // the same time
  storeUtils.useStore = () => {
    const state = {}
    const updates = {}
    keys.forEach((k) => {
      const [s, u] = storeUtils[`use${capitalize(k)}`]()
      state[k] = s
      updates[k] = u
    })

    function updater(newState) {
      const s =
        typeof newState === 'function' ? newState(state) : newState || {}
      Object.keys(s).forEach((k) => updates[k] && updates[k](s[k]))
    }

    return [state, updater]
  }

  // Return everything we've generated
  return storeUtils
}

Демо

Чтобы вы поняли, как это работает,я создал песочницу. В каждый компонент я добавил conosle.log, так что вы сможете увидеть, когда он перерисовывается. Пример очень простой, но вы сможете создать свои компоненты и состояние.

Заключение

Преимущество fragmented-store заключается в том, что она работает с React Context и нет необходимости создавать много контекстов вручную.

В примере и в библиотеке fragmented-store пока возможен лишь первый уровень фрагментации. Приветствуются любые улучшения на GitHub.


Teaful: крошечное, лёгкое и мощное средство управления состоянием на React

Оригинал второй части.

Недавно мы переписали fragmented-store — теперь она меньше, проще, мощнее и теперь называется Teaful. С момента создания библиотека называлась так:

Новый логотип Teaful
Новый логотип Teaful

Это окончательное название. Расскажу обо всех перечисленных преимуществах.

Что значит меньше?

Teaful меньше 1 Кб, так что много кода писать не нужно. Это делает проект намного легче:

874 B: index.js.gz
791 B: index.js.br
985 B: index.modern.js.gz
888 B: index.modern.js.br
882 B: index.m.js.gz
799 B: index.m.js.br
950 B: index.umd.js.gz
856 B: index.umd.js.br

Что значит проще?

Для работы со свойствами store иногда требуется много шаблонного кода: экшены, редьюсеры, селекторы, connect и т. д. Цель Teaful — быть очень лёгкой в использовании, работать со свойством и перезаписывать его без шаблонов. И вот результат:

Teaful: лёгкая в использовании, без шаблонного кода
Teaful: лёгкая в использовании, без шаблонного кода

Что значит мощнее?

Код на Teaful легко сопровождать, эта библиотека избавляет от лишних отрисовок и улучшает производительность сайта. Когда в store обновляется единственное свойство, уведомляется только компонент, который работает с этим обновлённым свойством:

Повторные отрисовки Teaful
Повторные отрисовки Teaful

Другие преимущества

В небольших проектах Teaful заменяет Redux или Mobx и дарит скорость. Большие проекты с ней сопровождать легче, а их код не раздувается.

Создание свойства store на лету

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

const { useStore } = createStore()

export function Counter() {
  const [count, setCount] = useStore.count(0); // 0 as initial value

  return (
    <div>
      <span>{count}</span>
      <button onClick={() => setCount(c => c + 1)}>
        Increment counter
      </button>
      <button onClick={() => setCount(c => c - 1)}>
        Decrement counter
      </button>
    </div>
  )
}

Работа с несколькими уровнями вложения свойств

Работать с любым свойством в любом месте store можно так:

const { useStore } = createStore({
  username: "Aral",
  counters: [
    { name: "My first counter", counter: { count: 0 } }
  ]
})

export function Counter({ counterIndex = 0 }) {
  const [count, setCount] = useStore.counters[counterIndex].counter.count();

  return (
    <div>
      <span>{count}</span>
      <button onClick={() => setCount(c => c + 1)}>
        Increment counter
      </button>
      <button onClick={() => setCount(c => c - 1)}>
        Decrement counter
      </button>
    </div>
  )
}

Сброс свойства store в исходное значение

В отличие от хуков React типа useState, в Teaful для сброса свойства в исходное значение есть третий элемент:

const { useStore } = createStore({ count: 0 })

export function Counter() {
  const [count, setCount, resetCounter] = useStore.count();

  return (
    <div>
      <span>{count}</span>
      <button onClick={() => setCount(c => c + 1)}>
        Increment counter
      </button>
      <button onClick={() => setCount(c => c - 1)}>
        Decrement counter
      </button>
      <button onClick={resetCounter}>
        Reset counter
      </button>
    </div>
  )
}

Это касается всех уровней. Вот так можно вернуть в исходное значение весь store:

const [store, setStore, resetStore] = useStore();
// ...
resetStore()

Использование нескольких store

Давайте создадим несколько store и переименуем хуки:

import createStore from "teaful";

export const { useStore: useCart } = createStore({ price: 0, items: [] });
export const { useStore: useCounter } = createStore({ count: 0 });

И задействуем их в компонентах:

import { useCounter, useCart } from "./store";

function Cart() {
  const [price, setPrice] = useCart.price();
  // ... rest
}

function Counter() {
  const [count, setCount] = useCounter.count();
  // ... rest
}

Кастомные объекты для обновления DOM

Чтобы несколько компонентов применяли одни и те же объекты с методами обновления DOM, определим их заранее с помощью вспомогательного метода getStore:

import createStore from "teaful";

export const { useStore, getStore } = createStore({ count: 0 });

const [, setCount] = getStore.count()

export const incrementCount = () => setCount(c => c + 1)
export const decrementCount = () => setCount(c => c - 1)

И используем в компонентах:

import { useStore, incrementCount, decrementCount } from "./store";

export function Counter() {
  const [count] = useStore.count();

  return (
    <div>
      <span>{count}</span>
      <button onClick={incrementCount}>
        Increment counter
      </button>
      <button onClick={decrementCount}>
        Decrement counter
      </button>
    </div>
  )
}

Оптимистичные обновления

Благодаря функции onAfterUpdate возможно выполнить оптимистичное обновление. Иными словами, можно обновить store и сохранить значение, вызывая API, а при сбое вызова вернуться к предыдущему значению:

import createStore from "teaful";

export const { useStore, getStore } = createStore({ count: 0 }, onAfterUpdate);

function onAfterUpdate({ store, prevStore }) {
  if(store.count !== prevStore.count) {
    const [count, setCount, resetCount] = getStore.count()

    fetch('/api/count', { method: 'PATCH', body: count })
    .catch(e => setCount(prevStore.count))
  }
}

При этом не нужно изменять компоненты:

import { useStore } from "./store";

export function Counter() {
  const [count, setCount] = useStore.count();

  return (
    <div>
      <span>{count}</span>
      <button onClick={() => setCount(c => c + 1)}>
        Increment counter
      </button>
      <button onClick={() => setCount(c => c - 1)}>
        Decrement counter
      </button>
    </div>
  )
}

Чтобы оптимистичное обновление касалось только одного компонента, зарегистрируем его таким образом:

const [count, setCount] = useStore.count(0, onAfterUpdate);

Вычисляемые свойства store

Чтобы cart.priceвсегда был вычисленным значением другого свойства, например из cart.items, используем функцию onAfterUpdate:

export const { useStore, getStore } = createStore(
  {
    cart: {
      price: 0,
      items: ['apple', 'banana'],
    },
  },
  onAfterUpdate,
);

function onAfterUpdate({ store, prevStore }) {
  calculatePriceFromItems()
  // ...
}

function calculatePriceFromItems() {
  const [price, setPrice] = getStore.cart.price(); 
  const [items] = getStore.cart.items();
  const calculatedPrice = items.length * 3;

  if (price !== calculatedPrice) setPrice(calculatedPrice);
}

И снова не нужно изменять компоненты:

import { useStore } from "./store";

export function Counter() {
  const [price] = useStore.cart.price();

  // 6€
  return <div>{price}€</div>
}

Узнайте больше о Teaful

Я рекомендую заглянуть в README и ознакомиться с документацией, посмотреть все варианты и узнать, с чего начать. В документации есть раздел с примерами, который будет пополняться.

Teaful находится на ранней стадии разработки. К версии 1.0 библиотека должна стать ещё меньше, легче и мощнее. Сообщество библиотеки быстро растёт, мы приветствуем любые предложения. Я благодарю всех, кто внёс свой вклад в код Teaful.

Продолжить изучение React вы сможете на наших курсах:

Узнайте подробности акции.

Другие профессии и курсы

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


  1. markelov69
    25.11.2021 22:57

    Иммутабильный, не реактивный. Ужас. Очередная бесполезная поделка.


  1. john_samilin
    26.11.2021 09:56
    +2

    Я понял. (Не хочу быть душным и не имею в виду конкретно создателей Teaful) Это выпускники курсов по реакту берут свои учебные наработки ("создай свой редакс за час") и релизят по сто штук в месяц


  1. kodart
    26.11.2021 09:58

    memo не решает проблему ре-рендеринга?


  1. yroman
    26.11.2021 10:41
    +1

    Полагаю, поддержки Type Script нет и не предвидится.


    1. faiwer
      26.11.2021 13:04
      +1

      В принципе TS 4.5 уже может типизировать такие вещи. Там главная сложность в том что нужно генерировать useCount из { count: 1 }. Но последние версии Typescript этому научились.


  1. 777Polar_Fox777
    26.11.2021 11:48
    +2

    А что на счёт поддержки TypeScript?


  1. faiwer
    26.11.2021 12:52

    Безотносительно самой библиотеки. Касаясь только КДПВ (картинки для привлечения внимания) статьи:



    Нет я конечно понимаю, что всю эту библиотеку одним useState не заменить, но неужели нельзя было без вот этих нелепых манипуляций обойтись?


    1. jMas
      26.11.2021 18:36

      Насколько я понимаю, в таком случае, локальный стейт заперт внутри компонента.


      1. faiwer
        26.11.2021 18:55
        +1

        Да. Всё так. Но обратите внимание на код — этот state только локально и используется.
        То есть для демонстрации примера изолированного от компонентов store надо было нарисовать совсем другую картинку :)


  1. Alexandroppolus
    26.11.2021 13:23

    Чтобы cart.priceвсегда был вычисленным значением другого свойства, например из cart.items, используем функцию onAfterUpdate:

    Оу, каскадные обновления стейта! Вроде бы известный антипаттерн (по крайней мере в МобХ автор настоятельно советует вместо этого использовать компутеды).


    1. JustDont
      26.11.2021 13:48

      Прикольно представить, как это прекрасно всё отмасштабируется на плохоньком реальном сторе в пару сотен входящих смысловых единиц данных и пару же сотен — вычисленных.
      Не говоря уж про то, что это не ленивые вычисления. Ну право слово, почему бы и не посчитать сразу всё, вне зависимости, надо ли оно нам, или не надо. Компутер железный, он для этого и создан.


  1. faiwer
    26.11.2021 13:26

    Заключение. Преимущество fragmented-store заключается в том, что она работает с React Context и нет необходимости создавать много контекстов вручную.

    Признаться сама идея использовать контексты для каждой отдельной взятой переменной мне показалась слишком уж бредовой. Я полез в исходный код. Там нет никаких контестов. Там просто another one реализация observable, но очень очень примитивная, и очень обильно эксплуатирующая Proxy. Вот все её зависимости:


    import {useEffect, useReducer, createElement} from 'react';

    Как видите тут нет никакого useContext и createContext. У неё своя собственная реализация observable. По сути это просто ещё один глобальный store с forceRerender-ом.


    А теперь посмотрим на самую первую версию:


    import React, { useState, useContext, createContext } from "react";

    Вот в ней и были все эти бредовые потуги с контекстами на каждую переменную. Благо они ушли в небытие.


    Рекомендация автору (статьи на хабре) — удалить этот шедевр.