После того, как я пишу интерфейсный код в течение еще семи лет, дорабатывая проекты, переосмысливая, я чувствую себя все более и более уверенным в том, что некоторые из вещей, которые используются ежедневно, должны быть подвергнуты сомнению, один из которых касается сегодняшних широко принятых практик в управлении состоянием. 🙏. Я бы поделился некоторыми мыслями в этой статье для исследования.

Почему бы не использовать Redux, Recoil, MobX, Zustand, Jotai или Valtio

К настоящему времени, особенно в React, существует множество библиотек управления состоянием. Итак, чтобы наглядно описать проблемы, я бы начал тему с библиотек в React.

Во-первых, давайте посмотрим на Redux. Когда простое действие изменяет одно состояние, то, какое именно состояние оно изменяет, можно ясно понять, только проверив, в каком слайсе оно объявлено. Это хорошо:

const checkboxSlice = createSlice({
  name: 'checkbox',
  initialState: {
    checked: false,
  },
  reducers: {
    check(state) {
      // ...
    },
  },
});

const { check } = checkboxSlice.actions;

// ...

dispatch(check());

// By checking `check` is declared in `checkboxSlice`, we know, by the design of Redux, `check` changes the one state represented by `checkboxSlice`.

Но когда сложное действие изменяет несколько состояний, то, какие состояния оно изменяет, нельзя понять, просто проверив, где оно объявлено. Это не хорошо:

const checkboxSlice = createSlice({
  name: 'checkbox',
  initialState: {
    checked: false,
  },
  reducers: {
    check(state) {
      // ...
    },
+
+    uncheck(state) {
+      // ...
+    }
  },
});

-const { check } = checkboxSlice.actions;
+const { check, uncheck } = checkboxSlice.actions;

// The underlying simple action `uncheck` needs to be built in advance for the complicated action `uncheckWithTextCleaned` but may never get invoked anywhere else.
const textareaSlice = createSlice({
  name: 'textarea',
  initialState: {
    text: '',
  },
  reducers: {
    setText(state, action: PayloadAction<string>) {
      // ...
    },
  },
});

const { setText } = textareaSlice.actions;

function uncheckWithTextCleaned(): AppThunk {
  return (dispatch) => {
    // ...
  };
}

// ...

dispatch(uncheckWithTextCleaned());

// By only checking the function declaration of `uncheckWithTextCleaned`, we don't know what multi states `uncheckWithTextCleaned` changes.

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

function uncheckWithTextCleaned(): AppThunk {
  return (dispatch) => {
    dispatch(uncheck());
    dispatch(setText(''));
  };
}

// ...

dispatch(uncheckWithTextCleaned());

// By tracking `uncheckWithTextCleaned` invokes `uncheck` declared in `checkboxSlice` and `setText` declared in `textareaSlice`, we know `uncheckWithTextCleaned` changes the multi states represented by `checkboxSlice` and `textareaSlice`.

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

Далее давайте проверим Recoil и MobX. В Recoil изменение состояния определяется хуками, изменяющими состояние:

const checkboxState = atom({
  key: 'checkbox',
  default: {
    checked: false,
  },
});

const textareaState = atom({
  key: 'textarea',
  default: {
    text: '',
  },
});

// ...

function useSetText() {
  return useRecoilCallback(
    ({ set }) =>
      (text: string) => {
        // ...
      },
    []
  );
}

function useUncheckWithTextCleaned() {
  const setText = useSetText();

  return useRecoilCallback(
    ({ set }) =>
      () => {
        // ...
      },
    []
  );
}

// ...

const uncheckWithTextCleaned = useUncheckWithTextCleaned();

// ...

uncheckWithTextCleaned();

// By only checking the function declaration of `uncheckWithTextCleaned` or `useUncheckWithTextCleaned`, we don't know what states the hook changes. To know that, what set calls the hook directly or indirectly invokes needs to be figured out by tracking function bodies.

В MobX изменение состояния определяется методами хранилища:

class CheckboxStore {
  private textareaStore: TextareaStore;

  checked: boolean;

  constructor(textareaStore: TextareaStore) {
    makeAutoObservable(this);
    this.textareaStore = textareaStore;
    this.checked = false;
  }

  uncheckWithTextCleaned(): void {
    // ...
  }
}

class TextareaStore {
  text: string;

  constructor() {
    makeAutoObservable(this);
    this.text = '';
  }

  setText(text): void {
    // ...
  }
}

// ...

checkboxStore.uncheckWithTextCleaned();

// By only checking the function declaration of `checkboxStore.uncheckWithTextCleaned`, we don't know what states the method changes. To know that, what store properties the method directly or indirectly changes needs to be figured out by tracking function bodies.

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

Кроме того, поскольку Recoil очень заботится об асинхронности, получение состояний в контексте хука, изменяющего состояние, неудобно. Поскольку у MobX есть собственный независимый механизм подписки, для его правильного использования требуется четкое понимание. В результате они еще больше увеличивают стоимость.

Что касается остальных 3 библиотек, то Zustand, Jotai и Valtio на самом деле очень похожи на Redux, Recoil и MobX по отдельности. Другими словами, я бы сказал, что первые являются просто облегченными версиями последних, поэтому имеют те же проблемы.

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

Предсказуемость и побочные эффекты

Говорят, что функция имеет побочные эффекты, если она производит какие-либо другие эффекты, кроме вывода возвращаемого значения. В приведенных выше примерах побочным эффектом функций является изменение всех состояний.

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

С другой стороны, функция без побочных эффектов естественно ведет себя предсказуемо.

Затем, чтобы решить проблему предсказуемости изменения состояний, я могу либо постоянно контролировать побочные эффекты функций изменения состояния, либо полностью устранять побочные эффекты функций изменения состояния.

Общая стоимость разработки при использовании и предпочтениях

В то время как проблема предсказуемости увеличивает общую стоимость разработки при использовании, предпочтения библиотек управления состоянием также могут увеличить стоимость. Как и в приведенных выше примерах, создание нового хранилища в Redux, получение состояний в хуках изменения состояния в Recoil и правильное использование механизма подписки в MobX обходятся дорого только из-за предпочтений каждой библиотеки.

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

Другой способ управления государством

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

Для изменений с одним состоянием можно использовать чистую функцию, которая обрабатывает старое одно состояние, чтобы вернуть новое одно состояние:

function check(checkboxState: CheckboxState): CheckboxState {
  return {
    /* ... */
  };
}

Для изменений с несколькими состояниями можно использовать чистую функцию, которая обрабатывает старые множественные состояния для возврата новых множественных состояний:

function uncheckWithTextCleaned([checkboxState, textareaState]: [
  CheckboxState,
  TextareaState
]): [CheckboxState, TextareaState] {
  return [
    /* ... */
  ];
}

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

function setText(textarea: TextareaState, text: string): TextareaState {
  return {
    /* ... */
  };
}

Поскольку чистые функции не имеют побочных эффектов, использование функций для изменения одного или нескольких состояний изменяет только состояния в объявлениях функций, что означает, какие состояния они изменяют, можно четко понять, не отслеживая тела функций.

После этого этапы изменения состояния при использовании функций могут работать следующим образом: (1) чтение старых состояний, (2) передача старых состояний в функции для оценки новых состояний и (3) запись новых состояний:

const oldCheckboxState = getState(keyOfCheckboxState);
const newCheckboxState = check(oldCheckboxState);
setState(keyOfCheckboxState, newCheckboxState);

Эти шаги также можно определить как повторно используемую функцию operate:

operate(keyOfCheckboxState, check);
operate(keyOfTextareaState, setText, '');
operate([keyOfCheckboxState, keyOfTextareaState], uncheckWithTextCleaned);

Затем был сформирован прототип.

Далее следует найти больше улучшений с точки зрения снижения общей стоимости разработки при использовании.

При более внимательном рассмотрении первого параметра keyOf... из operate я понимаю, что его роль заключается в (1) идентификации состояний. Но объявление списка уникальных строк для идентификации состояний обходится очень дорого. Между тем, чтобы полностью определить состояния, необходимо что-то еще для (2) размещения состояний по умолчанию и (3) объявления типов состояний. К счастью, когда все три совета собраны вместе, я обнаружил, что в JS есть только соответствующая концепция, а именно Plain Old JavaScript Object (POJO). Таким образом, стоимость может еще больше снизиться путем определения состояний с помощью POJO:

interface CheckboxState {
  checked: boolean;
}

const defOfCheckboxState: CheckboxState = {
  checked: false,
};

interface TextareaState {
  text: string;
}

const defOfTextareaState: TextareaState = {
  text: '',
};

// ...

operate(defOfCheckboxState, check);
operate(defOfTextareaState, setText, '');
operate([defOfCheckboxState, defOfTextareaState], uncheckWithTextCleaned);

После этого остальные части для основных применений управления состоянием будут добавлены без предпочтений, включая (1) получение состояний, (2) подписку на изменения состояния и (3) отписку:

const checkboxState1 = snapshot(defOfCheckboxState);
const textareaState1 = snapshot(defOfTextareaState);
const [checkboxState2, textareaState2] = snapshot([
  defOfCheckboxState,
  defOfTextareaState,
]);

const unsubscribeCheckboxStateChanges = subscribe(
  defOfCheckboxState,
  onCheckboxStateChange
);
const unsubscribeTextareaStateChanges = subscribe(
  defOfTextareaState,
  onTextareaStateChange
);
const unsubscribeCheckboxTextareaStatesChanges = subscribe(
  [defOfCheckboxState, defOfTextareaState],
  onCheckboxTextareaStatesChange
);

Затем была построена еще одна библиотека управления состоянием, которая хорошо справляется с проблемами (1) предсказуемости изменения состояний и (2) общей стоимостью разработки при использовании.

Проспект

Управление состоянием является очень простой, но очень важной частью разработки внешнего интерфейса, но сегодня просто нет достаточно хорошей библиотеки, которая одновременно обеспечивает предсказуемое изменение состояний и низкую общую стоимость разработки при использовании, что ставит ежедневные задачи в разработке.

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

Кроме того, для удобства совместных испытаний я последовал мыслям выше и попытался собрать библиотеку управления состоянием https://github.com/statofu/statofu.

Комментарии приветствуются в любом месте и в любое время для изучения лучшего управления состоянием.