Постановка задачи

Представьте: у вас есть таблица с данными, которые можно редактировать. Как мы это оформим?

В начале создадим наш проект через бойлерплейт утилиту npx create-react-app

В компоненте app.js получим список космических кораблей и положим их в хранилище redux (позднее будет понятно, почему именно туда):

// app.jsx

const App = () => {
  const dispatch = useDispatch();

  useEffect(() => {
    const getNews = async () => {
      const {data} = await axios({
        method: `get`,
        url: `http://swapi.dev/api/vehicles`
      })

      dispatch(setStarships(data.results))
    }
    getNews();
  }, [])

  return (
    <div className="App">
      <Table />
    </div>
  );
}

Построим простую таблицу:

// table.jsx

const Table = () => {
  const starships = useSelector(({ starships }) => starships.starships);

  return (
    starships
      ? <div className="table">
        <TableHeader />
        {starships.map((starship, idx) => <TableRow key={idx} starship={starship} />)}
      </div>
      : <div>loading...</div>
  )
};

Строка таблицы:

// table-row.jsx

const TableRow = ({starship}) => {
  const {
    cargo_capacity,
    cost_in_credits,
    max_atmosphering_speed,
    name
  } = starship


  return (
    <div className="table__row">
      <TableCell item={cargo_capacity} />
      <TableCell item={cost_in_credits} />
      <TableCell item={max_atmosphering_speed} />
      <TableCell item={name} />
    </div>
  )
};

Ячейка:

// table-cell.jsx

const TableCell = ({ item }) => {
  const [state, setState] = useState(item);

  return (
    <div className="table__cell">
      <input
        value={state}
        onChange={({ target }) => setState(target.value)}
        type="text" />
    </div>
  )
}

Чтобы при изменении стейта перерисовывалась только одна, ячейка мы кладём значение из пропсов в стейт компонента и меняем только его. При изменении значения в ячейке, происходит перерисовка одной ячейки. (Для хорошей видимости flash updates, я убрал outline у input в состоянии :focus, не делайте так!)

Готово! Вы великолепны!

Github исходник

Массовое обновление

Но что делать, если надо обновить значения стейта нескольких ячеек одновременно? Примерно как в google sheets и чтобы ещё выделять ячейки можно было? Воспользуемся библиотекой https://mobius1.github.io/Selectable/index.html

npm i selectable.js

В компоненте Table создадим экземпляр selectable

// table.jsx

const Table = ({ starships }) => {

  const escKeyDownHandler = useCallback((evt) => {
    if (evt.key === `Escape` || evt.key === `Esc`) {
      evt.preventDefault();
      window.select.clear();
    }
  }, [])

useEffect(() => {

    window.select = new Selectable({
      appendTo: `.table`,
      autoRefresh: false,
      lasso: {
        border: '1px solid blue',
        backgroundColor: 'rgba(52, 152, 219, 0.1)',
      },

      ignore: [
        `input`
      ],
    })

  }, []);
  
useEffect(() => {
    document.addEventListener(`keydown`, (evt) => escKeyDownHandler(evt));
    return document.removeEventListener(`keydown`, escKeyDownHandler);
  }, [escKeyDownHandler]);
  
  return (
  ...
      )
};

Добавим в ignore input, чтобы при фокусе в нём, библиотека не реагировала на него. Также сразу создадим обработчик события на esc чтобы при клике на эту клавишу выделение сбрасывалось.

Теперь, печатая в одной ячейке, нам нужно изменить значения во всех выделенных ячейках. Но как это сделать? В React однонаправленный поток данных и мы не можем из одного ребёнка поменять значение другого. Функцию изменения стейта придётся класть в родителя или воспользоваться апдейтом через redux, что мы и сделаем.

Вариант первый. Самый быстрый и неоптимальный

Для начала изменим передачу пропсов в компонент TableCell

Было:

<TableCell item={cargo_capacity} />

Стало:

<TableCell url={url} item={{cargo_capacity}} />

Это нам понадобилось, чтобы получить в компоненте имя ячейки и url как универсальный идентификатор строчки

Меняем компонент TableCell:

// table-cell.jsx

const TableCell = ({ item, url }) => {
  const [[name, value]] = Object.entries(item)
...
  const updateHandler = ({ target }) => {
    const { value } = target;
    const selectedFields = document.querySelectorAll(`.ui-selected`);

    if (selectedFields.length) {
      selectedFields.forEach(({ dataset }) => {
        const { name, id } = dataset
        dispatch(updateStarship({ value, fieldName: name, url: id }))
      })
    } else {
      dispatch(updateStarship({ value, fieldName: name, url }))
    }

  }
  return (
  ...
   <input
        value={value}
        onChange={updateHandler}
        type="text" />
    ...
      </div>
  )
}

Библиотка Selectable вешает на выбранную ячейку класс ui-selected. До этого мы присвоили класс ui-selectable каждому DOM элементу, который может быть выбран. Добавим data- атрибуты id и name для идентификации ячейки.

Теперь напишем функцию обновления стейта:

// table-cell.jsx

const TableCell = ({ item, url }) => {
...
  const updateHandler = ({ target }) => {
    const { value } = target;
    const selectedFields = document.querySelectorAll(`.ui-selected`);

    if (selectedFields.length) {
      selectedFields.forEach(({ dataset }) => {
        const { name, id } = dataset
        dispatch(updateStarship({ value, fieldName: name, url: id }))
      })
    } else {
      dispatch(updateStarship({ value, fieldName: name, url }))
    }

  }
  return (
  ...
   <input
        value={value}
        onChange={updateHandler}
        type="text" />
	...
      </div>
  )
}

Теперь в файле starships-reducer.js напишем функцию updateStarship :

// starships-reducer.js

import { createSlice } from '@reduxjs/toolkit';

export const starshipsSlice = createSlice({
...
	reducers: {
	...
      updateStarship: (state, action) => {
      const { value, fieldName, url } = action.payload
      const updatingItem = state.starships.find(starship => starship.url === url);
      const updatingIndex = state.starships.findIndex(starship => starship.url === url);

       if (updatingIndex < 0) {
        throw new Error(`no such index`);
      }
        
      updatingItem[fieldName] = value;
      
      state.starships = [
        ...state.starships.slice(0, updatingIndex),
        updatingItem,
        ...state.starships.slice(updatingIndex + 1),
      ]

    }
	}
  ...

Теперь апдейт выглядит так:

Готово. Вы великолепны!

Github исходник

Но почти... Теперь при обновлении одной ячейки у вас будет перерисовываться вся таблица. И, если в таблице такого размера это не критично - в большой таблице вас ждут большие проблемы. Мне по работе пришлось делать массовый апдейт в таблице 23x200, т.е. в DOM присутствовало одномоментно 4600 ячеек. Представьте, какие фризы это вызывало. И нельзя было воспользоваться библиотекой вроде react-window, так как она хранит в DOM одновременно только несколько строк. А нам нужно обновлять одновременно вплоть до двухсот строк, не говоря уже о количестве ячеек.

Вариант второй: хитрый

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

Для этого нам надо переписать функцию апдейта в TableCell

// table-cell.jsx

const TableCell = ({ url, item }) => {
  ...
  
  const updateHandler = ({ target }) => {
    const { value } = target;
    const selectedFields = document.querySelectorAll(`.ui-selected`);

    if (selectedFields.length) {
      selectedFields.forEach((item) => {
        const input = item.children[0];
        input.value = value;
      })
    } 
    setState(value)

  }

  return (
    ...
  )
}

Как видите - здесь мы напрямую меняем значение остальных ячеек через input.value значения в ячейках меняются, но стейт внутри не обновляется. Происходит перерисовка только одной ячейки:

Вот что происходит со стейтом ячеек:

Этот способ нарушает философию библиотеки React, но какое-то время он был единственным, который я смог придумать.

Github исходник

Вариант третий: каноничный

Мозг - удивительная штука. Можно очень долго биться над решением какой-то задачи и на ум ничего не приходит, ты словно в стену упираешься, решение не приходит. Но стоит отвлечься, пойти на прогулку, заняться другими вещами - мозг в фоновом режиме продолжает биться над решением задачи. И когда происходит "Эврика" - он выплёвывает практически готовое решение в сознание. Такое со мной происходило множество раз, то же самое случилось и в этот. Сейчас я продемонстрирую своё финальное решение.

Для начала переработаем redux-store:

// starships-reducer.js

export const starshipsSlice = createSlice({
  name: 'starships',
  initialState: {
    ...
    // создаём новые списки
    cargo_capacity: [],
    cost_in_credits: [],
    max_atmosphering_speed: [],
    name: [],
  },
  reducers: {
		...
    // кладём значения в эти списки
    setItemsList: (state, action) => { 
      const {listName, list} = action.payload
      state[listName] = list
    },
  },
})

Затем в компоненте App при получении данных с сервера записываем значения в эти списки:

// app.jsx

const App = () => {
	...

  useEffect(() => {
    if (!starships) {
      const getNews = async () => {
        const { data } = await axios({
          method: `get`,
          url: `http://swapi.dev/api/vehicles`
        })

        const { results } = data
        dispatch(setStarships(results));

        // пустые массивы
        let cargo_capacity = [];
        let cost_in_credits = [];
        let max_atmosphering_speed = [];
        let name = [];

        // пробегаемся по массиву кораблей и записываем значения
        // важно: к каждому элементу массива добавляем url как идентификатор
        for (const starship of results) {
          cargo_capacity.push({
            url: starship.url,
            value: starship.cargo_capacity
          })
          cost_in_credits.push({
            url: starship.url,
            value: starship.cost_in_credits
          })
          max_atmosphering_speed.push({
            url: starship.url,
            value: starship.max_atmosphering_speed
          })
          name.push({
            url: starship.url,
            value: starship.name
          })
        };

        // записываем значения в стор
        dispatch(setItemsList({ listName: `cargo_capacity`, list: cargo_capacity }))
        dispatch(setItemsList({ listName: `cost_in_credits`, list: cost_in_credits }))
        dispatch(setItemsList({ listName: `max_atmosphering_speed`, list: max_atmosphering_speed }))
        dispatch(setItemsList({ listName: `name`, list: name }))
      }

      getNews();
    }
  }, [starships])

  return (
		...
  );
}

export default App;

Теперь передаём эти значения в компонент TableCell. В пропсы компонента передаём index для получения нужного элемента в селекторе.

// table-cell.jsx

const TableCell = ({ url, item, index }) => {
  const dispatch = useDispatch();
  // теперь нам нужно только название списка
  const [name] = Object.keys(item)

  // используем хитрый селектор. извлекаем только тот элемент,
  // который нам нужен в этом компоненте
  const itemValue = useSelector(({ starships }) => starships[name][index]);

  const updateHandler = ({ target }) => {
    const { value } = target;
    const selectedFields = document.querySelectorAll(`.ui-selected`);

    if (selectedFields.length) {
      selectedFields.forEach(({ dataset }) => {
        const { id, name } = dataset;
        
        // во всех выделенных ячейках меняем стейт, передаём туда 
        // значение, название списка и url как индентификатор
        dispatch(updateItem({ value, listName: name, url: id }));
      })
    } else {
      dispatch(updateItem({ value, listName: name, url }));
    }
  }


  return (
    <div
      data-id={url}
      data-name={name}
      className="table__cell ui-selectable">
      <input
        // больше не используем функцию локального стейта,
        // берём значение из стора
        value={itemValue ? itemValue.value : `loading...`}
        onChange={updateHandler}
        type="text" />
    </div>
  )
}

В редьюсере напишем функцию обновления нужного элемента в списке:

// starships-reducer.js

export const starshipsSlice = createSlice({
  name: 'starships',
  initialState: {
    ...
  },
  reducers: {
    ...
		updateItem: (state, action) => {
      // достаём значение, название списка и url
      const { value, listName, url } = action.payload

      const updatingItem = state[listName]
        .find(listItem => listItem.url === url);

      const updatingIndex = state[listName]
        .findIndex(listItem => listItem.url === url);

      if (updatingIndex < 0) {
        throw new Error(`no such index`);
      }

      updatingItem.value = value;

      state[listName] = [
        ...state[listName].slice(0, updatingIndex),
        updatingItem,
        ...state[listName].slice(updatingIndex + 1),
      ]
    },
  },
})

Как теперь выглядит обновление значений в ячейках таблицы:

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

// table-cell.jsx

const TableCell = ({ url, item, index }) => {
	...
  
  const updateHandler = ({ target }) => {
		...

    if (selectedFields.length) {
      selectedFields.forEach(({ dataset }) => {
        const { id, name: listName } = dataset;
        
        // пишем проверку на соответствие названия списка выделенной
        // ячейки списку ячейки, в которой мы пишем значение
        if (listName === name) {
          dispatch(updateItem({ value, listName, url: id }));
        }
      })
    } else {
      dispatch(updateItem({ value, listName: name, url }));
    }
  }


  return (
		...
  )
}

Теперь это выглядит так:

Готово! Теперь вы действительно великолепны!

Github исходник

Заключение

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

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


  1. DmitryKazakov8
    31.10.2021 12:56
    +2

    Да выбросьте вы из головы Redux, крайне неэффективный стейт-менеджер. Понимаю, что взяли его скорее для опыта и понимания работы легаси-проектов, но на том же MobX сделать намного проще. Вот пример реализации на нем + TS.

    Импорты и типы

    import _ from 'lodash';
    import axios from 'axios';
    import { observer } from 'mobx-react';
    import { autorun, observable, runInAction, toJS } from 'mobx';
    import { ChangeEvent, Component, ContextType, createContext } from 'react';
    import Selectable from 'selectable.js';
    
    import styles from './Table.scss';
    
    type TypeStarship = {
      url: string;
      name: string;
      crew: string;
      model: string;
      length: string;
      edited: string;
      created: string;
      passengers: string;
      consumables: string;
      manufacturer: string;
      vehicle_class: string;
      cargo_capacity: string;
      cost_in_credits: string;
      max_atmosphering_speed: string;
    };

    Контекст для прямого доступа из дочерних компонентов

    // eslint-disable-next-line @typescript-eslint/naming-convention
    const StarshipsStore = createContext<{ starships: Array<TypeStarship> }>({ starships: [] });
    
    type TypeStarshipsStoreContext = ContextType<typeof StarshipsStore>;
    
    class ConnectedComponentStarship<TProps = any> extends Component<TProps> {
      static context: TypeStarshipsStoreContext;
      static contextType = StarshipsStore;
      declare context: TypeStarshipsStoreContext;
    }

    Верхняя обертка с пробросом контекста

    export class App extends Component {
      render() {
        return (
          <StarshipsStore.Provider value={observable({ starships: [] })}>
            <Table />
          </StarshipsStore.Provider>
        );
      }
    }

    Верхний компонент таблицы. В нем загружается контент, набрасывается Selectable и трекаются измененные значения в ячейках

    @observer
    class Table extends ConnectedComponentStarship {
      localState: { prevStarships: Array<TypeStarship> } = observable({
        prevStarships: [],
      });
    
      select: any;
      trackDisposer: IReactionDisposer | null = null;
    
      componentDidMount() {
        void axios({
          method: `get`,
          url: `http://swapi.dev/api/vehicles`,
        }).then((result: any) => {
          runInAction(() => {
            this.context.starships = result.data.results;
            this.localState.prevStarships = result.data.results;
          });
    
          this.select = new Selectable({
            appendTo: `.${styles.table}`,
            filter: `.${styles.tableCell}`,
            autoRefresh: false,
            lasso: {
              border: '1px solid blue',
              backgroundColor: 'rgba(52, 152, 219, 0.1)',
            },
            ignore: [`input`],
          });
    
          document.addEventListener(`keydown`, this.escKeyDownHandler);
    
          this.trackChangedStarships();
        });
      }
    
      escKeyDownHandler = (evt: any) => {
        if (evt.key === `Escape` || evt.key === `Esc`) {
          evt.preventDefault();
          this.select.clear();
        }
      };
    
      trackChangedStarships = () => {
        this.trackDisposer = autorun(() => {
          if (_.isEqual(this.localState.prevStarships, this.context.starships)) return;
    
          const changedStarships = _.differenceWith(
            this.localState.prevStarships,
            this.context.starships,
            _.isEqual
          );
    
          changedStarships.forEach((starship) => {
            console.log('starship data changed', toJS(starship));
          });
    
          runInAction(() => {
            this.localState.prevStarships = toJS(this.context.starships);
          });
        });
      };
    
      componentWillUnmount() {
        document.removeEventListener(`keydown`, this.escKeyDownHandler);
        this.trackDisposer?.();
      }
    
      render() {
        const { starships } = this.context;
    
        const renderedKeys: Array<keyof TypeStarship> = [
          'cargo_capacity',
          'cost_in_credits',
          'max_atmosphering_speed',
          'name',
        ];
    
        return (
          <div className={styles.table}>
            <TableHeader renderedKeys={renderedKeys} />
            {starships.map((starship, index) => (
              <TableRow key={index} starship={starship} renderedKeys={renderedKeys} />
            ))}
          </div>
        );
      }
    }

    Заголовки и строки таблицы. Добавил динамический вывод столбцов, по сравнению с оригинальным кодом

    @observer
    class TableHeader extends ConnectedComponentStarship<{ renderedKeys: Array<keyof TypeStarship> }> {
      render() {
        const { renderedKeys } = this.props;
    
        return (
          <div className={styles.tableRowHeader}>
            {renderedKeys.map((param) => (
              <div key={param} className={styles.tableCell}>
                {param}
              </div>
            ))}
          </div>
        );
      }
    }
    
    @observer
    class TableRow extends ConnectedComponentStarship<{
      starship: TypeStarship;
      renderedKeys: Array<keyof TypeStarship>;
    }> {
      render() {
        const { starship, renderedKeys } = this.props;
    
        return (
          <div className={styles.tableRow}>
            {renderedKeys.map((param) => (
              <TableCell key={param} starship={starship} param={param} />
            ))}
          </div>
        );
      }
    }

    Ячейка. Если выбрано несколько ячеек, то в хранилище целевой корабль ищется по url, значения мутируются одним батчем благодаря runInAction, в отличие от оригинального решения, где много последовательных dispatch. Если выбрана одна ячейка, значение просто мутируется.

    @observer
    class TableCell extends ConnectedComponentStarship<{
      starship: TypeStarship;
      param: keyof TypeStarship;
    }> {
      handleChange = (event: ChangeEvent<HTMLInputElement>) => {
        const { starships } = this.context;
        const { starship, param } = this.props;
    
        const selectedFields = document.querySelectorAll(`.ui-selected`);
    
        if (!selectedFields.length) {
          runInAction(() => (starship[param] = event.target.value));
    
          return;
        }
    
        runInAction(() => {
          selectedFields.forEach(({ dataset }) => {
            const targetStarship = starships.find((s) => s.url === dataset.url);
    
            if (targetStarship) targetStarship[dataset.param] = event.target.value;
          });
        });
      };
    
      render() {
        const { starship, param } = this.props;
    
        return (
          <div data-url={starship.url} data-param={param} className={styles.tableCell}>
            <input onChange={this.handleChange} value={starship[param]} type={'text'} />
          </div>
        );
      }
    }

    Также исправил ряд ошибок оригинального кода, но все равно это все - чисто для примера, в реальном проекте нужен будет ряд доработок (обработка ошибок, валидации ответа запроса, слой запросов, вместо нереактового Selectable я бы предпочел кастомное решение, без необходимости делать querySelectorAll) и т.п.

    В итоге обслуживающий код - минимальный, ответственность разделена (ячейки изменяют данные в хранилище, а высокоуровневый компонент Table независимо отслеживает измененные данные и может вызывать сохранение в базу). Благодаря этому можно изменять значения из-вне (по клику на кнопки вне Table, из модалок и т.п.).

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

    Также присмотритесь к CSS Components вместо строкового указания глобальных классов. К теме статьи не относится, но видеть БЭМ в 2021 как-то очень удивительно.

    Чтобы сделать изменение только одной колонки, достаточно добавить фильтр

    selectedFields.filter(({ dataset }) => dataset.param === param)


    1. Makemanback Автор
      31.10.2021 16:19

      Благодарю за ответ, интересно было посмотреть на решение через MobX, с ним не работал раньше, попробую ваш вариант.

      Вопросы:

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

      2. Почему БЭМ в 2021 удивительно? Он прекрасно ложится на компонентный подход Реакта.


      1. mayorovp
        31.10.2021 19:01

        Почему классовые компоненты?

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


        Какие-нибудь TableHeader и TableRow и правда лучше как функцию оформить, а вот за оформление чего-то вроде Table через хуки надо по рукам бить.


        1. sovaz1997
          31.10.2021 20:41
          +2

          Проблема не в хуках, а в прослойке между монитором и креслом. Если делать все правильно, у вас не будет 100 строк в функциональном компоненте.


          1. mayorovp
            31.10.2021 22:15

            Разумеется, и "правильно" тут вовсе не писать функциональный компонент.


            1. sovaz1997
              31.10.2021 22:17

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


      1. DmitryKazakov8
        01.11.2021 23:18
        +1

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


        Писал несколько проектов на хуках, но вернулся к классам из-за удобства организации кода. Как писал выше, в хуках получается смешивание разнородной логики (сайд-эффекты, локальное хранилище, обработка пользовательских событий, асинхронные вызовы, управление жизненным циклом), они несемантичны, больше забот о равенстве по ссылкам и необходимости оптимизации, нет метода для componentWillMount — это прямо серьезный недостаток, так как я в проектах вызываю в нем асинхронные действия и дожидаюсь их выполнения для SSR (есть, конечно, и схожие библиотеки для хуков — но это лишняя зависимость и определенное усложнение). И в целом подход, когда внутри функции есть некое состояние (useState), которое хранится где-то внутри фреймворка и не изменяется при повторных вызовах функций — это как-то не по джавоскриптовому. При больших компонентах функция рендера раздувается из-за комбинации десятка(-ов) хуков, при этом они не имеют доступа к результатам выполнения друг друга, пропсам и контексту без явной передачи — в классах же методы легко комбинировать и в каждом можно получить к ним доступ и к локальному стейту.


        Это не значит, что не умею "готовить" функциональные компоненты и грамотно выделять кастомные хуки — просто с классами работать намного удобнее и организовывать код в них проще.


        БЭМ — это один из вариантов решения проблемы глобальной области видимости, когда создаются околоуникальные именования (хотя нередко они все же пересекаются и возникают баги). Минусы — длинные названия, нет автодополнения, нет быстрого перехода в стилевые файлы на конкретный класс, сложнее отслеживать наличие неиспользованных или отсутствующих классов, сложнее придумывать названия. Так как в реакт-проектах практически везде используется сборщик, то намного эффективнее использовать CSS Modules и получать все преимущества — автоматические суффиксы и префиксы по названию файла в наименованиях (исключают возможность пересечения), быстрые переходы сразу на нужный класс в стилях, автодополнение, возможность проверки неиспользуемых или отсутствующих классов. Это намного удобнее.


    1. DanUnited
      31.10.2021 17:15

      Не нужно советовать то, что вы не понимаете


      1. Makemanback Автор
        31.10.2021 17:15

        Вы о чём конкретно?


    1. nin-jin
      01.11.2021 06:37
      -1

      Да выбросьте вы из головы React, крайне неэффективный рендерер. Понимаю, что взяли его скорее для опыта и понимания работы легаси-проектов, но на том же $mol сделать намного проще. Вот пример реализации на нем + произвольные формулы в ячейках: https://habhub.hyoo.ru/#!author=nin-jin/repo=HabHub/article=10


  1. BigDflz
    31.10.2021 14:47
    +2

    с таким количеством кода - проще на ванильном js писать.


  1. Alex-111
    01.11.2021 10:54

    А как насчет варианта на контексте без внешнего стейт-менеджера?