Позволяет ли Redux писать функционально чистый код?

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Когда-то давно, мне повезло работать на проекте, где все обращения к backend были обернуты в action-creators с использованием библиотеки redux-thunk. Эта библиотека имеет культовое значение для экосистемы Redux, так как позволяет писать обращения к backend без применение генераторов, в отличие от redux-saga

export const getRepos = (username) => {
  return async (dispatch) => {
    const result = {
      repos: [],
    };
    dispatch(loadingIn Progress (true));
    try {
      /*await */ fetch(
        `https://api.github.com/users/${username}/repos?sort=updated`
      )
      .then((data) => data.json ())
      .then((json) => (result.repos = json));
    } finally {
      dispatch(loadingInProgress (false));
    }
    dispatch(loadingSuccess (result));
  };
};

В рамках NDA я не могу назвать организацию и опубликовать оригинал. Но я могу написать псевдокод. Как видно, в результате исполнения getRepos, из-за закомментированного ключевого слова await, в целевой reducer состояния улетит пустой массив repos. На боевом проекте же его просто забыли написать :-)

import React from "react";
import PropTypes from "prop-types"; 

class RepoList extends React.Component {
  componentDidMount = () => { 
    setTimeout(() => this.forceUpdate(), 1_000);
  };
  render = () => {
    const { repos, hasError, isLoading } = this.props;
    ...

Начинающий разработчик всё-таки смог получить список репозиториев по ссылке, но сделал это с применением forceUpdate(). Это создало плавающий баг, который не воспроизводился на тестовом контуре при финальном интеграционном тестировании

Проблема

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

Является ли эта функция pure функцией?

const sum = (a, b) => a + b;

На первый взгляд, да, однако…

const obj = new class {
  toString() { 
    return Math.random().toString(36).substring(7);
  };
};

console.log(sum(obj, obj)) // uretdln9iue
console.log(sum(obj, obj)) // 61347arNc9q
console.log(sum(obj, obj)) // 529n518wn718 

Верным вариантом написания pure функции будет функция с определенными входными параметрами

const sum = (a: number, b: number) => a + b;

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

interface IObj {
  toString(): string;
}

Рассмотрим исходный код примера применения селекторов в redux-form, страница Selecting Form Values Example.

import { connect } from 'react-redux'
import { formValueSelector } from 'redux-form' 

...

const selector = formValueSelector('selectingFormValues')
SelectingFormValuesForm = connect((state) => {
  const hasEmailValue = selector(state, 'hasEmail')
  const favoriteColorValue = selector(state, 'favoriteColor')
  const { firstName, lastName } = selector(state, 'firstName', 'lastName')
  return {
    hasEmailValue,
    favoriteColorValue,
    canSubscribeByEmail: firstName && lastName,
    fullName: `${firstName || ''} S{lastName || ''}`,
  };
})(SelectingFormValuesForm);

На мой взгляд код, представленный выше, следует назвать синхронным и процедурным, но никак не функциональным, так как расширение множества полей при поддержке кода можно назвать сайд-эффектом

Функция mapStateToProps при подобном использовании имеет бесконечный список возвращаемых значений, он расширяется при последующих итерациях разработки вместе с правками ui, осуществляющим определение свойств компонента.

Решение

Коллеги! Пишите свои шаблонизаторы. Возможно, я покажусь старомодным, но применение JSON шаблонов для генерации форм позволит убрать копипасту и решить следующие проблемы

Автоматизировать создание внутреннего состояния формы и сохранение изменений

Огромное колличество кода приложения приходится на копипасту Create-Read-Update-Delete. Если нужно восстановить ввод при повторном открытии страницы без сохранения, вы не обойдетесь без шаблонизатора

Принцип единой ответственности

Свойство canSubscribeByEmail внутри селектора выше не привязано к полю напрямую и в любой момент, при повторном использовании другим программистом, произойдет коллизия. JSON шаблон позволяет разнести коллбеки isDisabledisInvalidisVisible непосредственно к полям, что обеспечит единую ответственность

Можно упростить адаптивную верстку

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

Наличие шаблонизатора критично для списочных форм

Если для backend существует единый стандарт json:api, например, nest-paginate, то для frontend я часто натыкался на копипасту состояний фильтров, сортировок и верстки списочной формы через <List> и <ListItem>. Это неправильно.

Пример использования шаблонизаторов в коде

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

Списочная форма
Списочная форма
const filters = [
  {
    type: FieldType.Text,
    name: 'firstName',
    title: 'First name',
  },
  {
    type: FieldType.Text,
    name: 'lastName',
    title: 'Last name',
  },
  {
    type: FieldType.Text,
    name: 'occupation',
    title: 'Occupation',
  }
];

const sortModel = [
  {
    field: 'KPI',
    sort: 'asc',
  }
];

const columns = [
  {
    type: ColumnType.Component,
    headerName: 'Avatar',
    width: () => 65,
    phoneOrder: 1,
    minHeight: 60,
    element: ({ avatar }) => (
      /*<Box style={{ display: 'flex', alignItems: 'center' }}>
        <Avatar
          src={avatar}
          alt={avatar}
        />
      </Box>*/
    ),
  },
  {
    type: ColumnType.Compute,
    primary: true,
    field: 'name',
    headerName: 'Name',
    width: (fullWidth) => Math.max(fullWidth * 0.1, 135),
    compute: ({ firstName, lastName }) => `${firstName} ${lastName}`,  
  },
  {
    type: ColumnType.Text,
    field: 'occupation',
    headerName: 'Occupation',
    width: (fullWidth) => Math.max(fullWidth * 0.1, 115),
  },
  {
    type: ColumnType.Component,
    secondary: true,
    field: 'KPI',
    headerName: 'KPI index',
    width: (fullWidth) => Math.max(fullWidth * 0.15, 200),
    minHeight: 30,
    element: ({ KPI, id }) => (
      /*<div 
        style={{
          display: 'flex',
          justifyContent: 'flex-start',
          alignItems: 'center',
          cursor: 'pointer',
        }}
        onClick={(e) => {
          e.preventDefault();
          e.stopPropagation();
          ioc.routerService.push(`/indicators/${id}`)
        }}
      > 
        <span style={{
        color: KPI < 50 ? '#FA5F5A' : KPI < 70 ? '#FE9B31' : '#7FB537',
        display: 'flex',
        alignItems: 'center'
      }}>
        <span style={{ fontWeight: '900', marginRight: '1em' }}>
          {`${KPI}%`}
        </span>
          ({KPI < 50 ? 'Review needed' : KPI < 70 ? 'Warning' : 'High'})
        </span>
      </div>*/
    )
  },
  {
    type: ColumnType.Text,
    field: 'gender',
    headerName: 'Gender',
    width: (fullWidth) => Math.max(fullWidth * 0.08, 65),
  },
  {
    type: ColumnType.Text,
    field: 'age',
    headerName: 'Age',
    width: () => 50,
  },
  {
    type: ColumnType.Text,
    field: 'phone',
    headerName: 'Phone number',
    width: (fullWidth) => Math.max(fullWidth * 0.1, 150),
  },
  {
    type: ColumnType.Text,
    field: 'email',
    headerName: 'Email',
    width: (fullWidth) => Math.max(fullWidth * 0.15, 215),
  },
  {
    type: ColumnType.Component,
    field: 'countryFlag',
    headerName: 'Country',
    width: (fullWidth) => Math.max(fullWidth * 0.12, 150),
    element: CountryFlag,
  },
  {
    type: ColumnType.Action,
    headerName: 'Actions',
    sortable: false,
    width: (fullWidth) => Math.max(fullWidth * 0.05, 50),
  },
];

const actions = [
  {
    type: ActionType.Menu,
    options: [
      {
        action: 'resort-action',
      },
    ]
  },
];

const chips = [
  {
    label: 'High KPI',
    name: 'high_kpi',
    color: '#7FB537',
  },
  {
    label: 'Warning KPI',
    name: 'warning_kpi',
    color: '#FE9B31',
  },
  {
    label: 'Review KPI',
    name: 'review_kpi',
    color: '#FA5F5A',
  }
];

const rowActions = [
  {
    label: 'Show perfomace indicators',
    action: 'indicators-action',
  },
];

const heightRequest = () => window.innerHeight - 70;
const widthRequest = () => window.innerWidth - 20;

export const ProfilesPage = () => {

  const apiRef = useRef<IListApi>(null);

  const pickerHandler = async ({
    firstName,
    lastName,
  }, {
    limit,
    offset,
  }) => {

    let rows = await Promise.resolve(mock) as IRowData[];

    if (firstName) {
      rows = rows.filter((row) => row.firstName.includes(firstName));
    }

    if (lastName) {
      rows = rows.filter((row) => row.lastName.includes(lastName));
    }

    const { length: total } = rows;

    rows = rows.slice(offset, limit + offset);

    return {
      rows,
      total,
    };

  };

  const [selectedRows, setSelectedRows] = useState<RowId[]>([]);

  const handleRowAction = (person: IPerson) => {
    ioc.routerService.push(`/indicators/${person.id}`)
  }

  const handleAction = (name: string) => {
    if (name === 'create'){
      ioc.routerService.push(`/profiles-list/create`);
    }
  }

  const handleClick = (person: IPerson) => {
    ioc.routerService.push(`/profiles-list/${person.id}`);
  };

  const handleSelectedRows = (rows: RowId[]) => {
    setSelectedRows(rows)
    console.log(rows)
  };

  return (
    <ListTyped<IFilterData, IPerson>
      ref={apiRef}
      title="Profiles"
      filterLabel="Filters"
      selectionMode={SelectionMode.Multiple}
      heightRequest={heightRequest}
      widthRequest={widthRequest}
      rowActions={rowActions}
      actions={actions}
      filters={filters}
      columns={columns}
      handler={handler}
      onSelectedRows={handleSelectedRows}
      onRowAction={handleRowAction}
      onRowClick={handleClick}
      onAction={handleAction}
      sortModel={sortModel}
      chips={chips}
    />
  );
};
Форма элемента списка
Форма элемента списка

const fields = [
  {
    type: FieldType.Group,
    fieldBottomMargin: "0",
    fields: [
      {
        type: FieldType.Group,
        columns: "2",
        phoneColumns: '12',
        tabletColumns: '2',
        style: {
          overflow: 'hidden',
        },
        fields: [
          {
            type: FieldType.Component,
            element: ({
              avatar
            }) => (
              /*<AutoSizer target={document.body} selector={`.${MAIN_CONTENT}`}>
                {({ height }) => (
                  <img
                    style={{
                      background: '#0003',
                      height: height,
                      width: 'calc(100% - 10px)',
                      objectFit: 'contain',
                    }}
                    src={avatar}
                    loading="lazy"
                  />
                )}
              </AutoSizer>*/
            )
          },
          {
            type: FieldType.Rating,
            fieldBottomMargin: "0",
            name: "rating",
            defaultValue: 3
          }
        ]
      },
      {
        type: FieldType.Group,
        fieldBottomMargin: "0",
        columns: "10",
        phoneColumns: '12',
        tabletColumns: '10',
        fields: [
          {
            type: FieldType.Group,
            className: MAIN_CONTENT,
            fields: [
              {
                type: FieldType.Div,
                style: {
                  display: 'grid',
                  gridTemplateColumns: 'auto 1fr 1fr',
                },
                fields: [
                  {
                    type: FieldType.Checkbox,
                    fieldBottomMargin: "0",
                    title: "Enabled",
                  },
                  {
                    type: FieldType.Text,
                    outlined: false,
                    title: "Identificator",
                    name: "id",
                  },
                  {
                    type: FieldType.Group,
                    fields: [
                      {
                        name: "id",
                        type: FieldType.Text,
                        outlined: false,
                        title: "Outer ID",
                      },
                    ]
                  },

                ],
              },
              {
                name: 'firstName',
                type: FieldType.Text,
                title: 'First name',
                description: 'Required',
              },
              {
                name: 'lastName',
                type: FieldType.Text,
                title: 'Last name',
                description: 'Required',
              },
              {
                name: 'age',
                type: FieldType.Text,
                title: 'Age',
              },
              {
                name: 'occupation',
                type: FieldType.Text,
                title: 'Occupation',
                description: 'Required',
              },
              {
                name: 'KPI',
                type: FieldType.Text,
                title: 'KPI index',
              },
              {
                type: FieldType.Combo,
                title: "Gender",
                placeholder: "Choose",
                name: "gender",
                itemList: [
                  "Male",
                  "Female",
                  "Other"
                ]
              },
            ]
          },
          {
            type: FieldType.Group,
            fieldBottomMargin: "0",
            columns: "12",
            fields: [
              {
                type: FieldType.Line,
                title: "Contact Data"
              },
              {
                name: 'email',
                type: FieldType.Text,
                title: 'E-mail',
              },
              {
                name: 'country',
                type: FieldType.Text,
                title: 'Country',
              },
              {
                name: 'phone',
                type: FieldType.Text,
                title: 'Phone number',
              },
            ]
          },
        ]
      }
    ]
  }
]

export const OneProfilePage = ({
  id,
}) => {

  const [data, setData] = useState(null);

  const handleChange = (data: IPerson, initial: boolean) => {
    if (!initial) {
      setData(data);
    }
  };

  const handleSave = async () => {
    ...
  };

  const handleBack = () => {
    ioc.routerService.push(`/profiles-list`);
  };

  const handler = () => fetch(`/api/v1/profiles/${id}`);

  return (
    <>
      <Breadcrumbs
        title="Profiles"
        disabled={!data}
        subtitle={id}
        onSave={handleSave}
        onBack={handleBack}
      />
      <One
        fields={fields}
        handler={handler}
        onChange={handleChange}
      />
    </>
  );
};

Источник: https://habr.com/ru/post/669672/


Интересные статьи

Интересные статьи

На Хабре ни для кого не секрет, что в текущей повестке практически все сферы частного бизнеса вынуждены реагировать на происходящие изменения. Большое количество привычных всем нам зарубежных сервисов...
Возможно, вы уже читали о конкурсе Flutter Puzzle Hack и думаете о том, как проявить максимум творческих способностей. И мы вам в этом поможем, рассказав о том, как структурирована кодовая база нашего...
Сопроводительное письмо / Cover Letter — персонализированное письмо от вас человеку, контролирующему процесс найма на работу, на которую вы претендуете.Сопроводительное письмо — это не то же самое, чт...
Я несколько раз начинал читать статьи и серии «Введение в функциональное программирование», «Введение в Теорию Категорий» и даже «Введение в Лямбда Исчисление». Причем и русском, и на...
В интернет-магазинах, в том числе сделанных на готовых решениях 1C-Битрикс, часто неправильно реализован функционал быстрого заказа «Купить в 1 клик».