Немного Ramda для React и Redux

С последней моей записи прошло довольно много времени. Дело в том, что я все же нашел свою первую работу и теперь времени на сторонние проекты у меня стало меньше. Тем не менее, постараюсь более менее регулярно продолжать писать на интересные для меня (надеюсь, и для вас) темы.

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

Ramda для управления логикой приложения

Оператор pipe, а также ifElse, cond, andThen / otherwise, позволяют создавать краткие и выразительные функции, состоящие из более простых частей.

Например, нам требуется функция, которая бы делала GET запрос на сервер и получала бы массив каких-либо данных (пользователи, товары, продажи и т.д.) - назовем их entities. При этом требуется проверить, что статус ответа равен 200.

Функция GET запроса выглядит так:

async function fetchData(entity: string) { return await axios.get(`http://localhost:3002/${entity}`, { validateStatus: () => true }); }

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

export const fetchEntity = async <T>(entity: string): Promise<{ data: T[]; }> => { return R.pipe( R.always(entity), fetchData, R.andThen( R.ifElse( (data) => data.status !== 200, () => { throw new Error(`${entity} fetch error`); }, R.pick(['data']), )), R.otherwise(() => { throw new Error(`${entity} fetch error`); }), )(); };

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

  • Функция R.pipe последовательно выполняет функции, переданные в нее в качестве аргументов.
  • R.always(entity) - функция, ничего не делает, просто возвращает переменную entity (так как требуется именно функция). Это аналог записи () => entity.
  • fetchData принимает ответ с предыдущей функции - entity в качестве своего аргумента и делает GET запрос на сервер.
  • R.andThen и R.otherwise - аналог записи .then и .catch. В случае, если произошла ошибка, то R.otherwise вызывает функцию, пробрасывающую ошибку.
  • R.ifElse принимает 3 функции в качестве аргументов: первая функция должна вернуть true или false, исходя из чего запускается 2-ая или 3-я функции. Запись (data) => ...: здесь в качестве аргумента принимается ответ от предыдущей функции fetchData.
  • R.pick(['data']) является последней функцией в списке. Она получает объект и возвращает значение по ключу. В данном случае - это data, где должен содержаться интересующий нас массив.

В чем преимущества подобной записи функции для меня?

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

Во-вторых, ее довольно легко расширять, добавляя новые функции в любое место среди аргументов R.pipe.

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

Ramda для Redux Slices

Рекомендуемым инструментом при использовании Redux является библиотека @reduxjs/toolkit, которая берет на себя часть рутинных действий по подготовке к работе.

Использование Redux Toolkit работу действительно проще. Но, лично у меня, дискомфорт вызывает использование библиотеки Immer для изменения state хранилища. Immer оборачивает state в proxy и, соответственно, менять так, будто нас не интересует иммутабельность. Все мутации будут перехвачены на уровне прокси и на выходе мы получим новый state, старый при этом никак изменен не будет.

Например, если при запросе на сервер нам нужно поменять fetchingStatus на pending, то это может выглядеть так:

builder.addCase(fetchInitialData.pending, (state, _action) => { state.fetchingStatus = 'Fetching' })

Мне такой подход кажется не совсем удачным:

  • Код получается не таким, каким хотелось бы его видеть. А именно, без мутаций state, причем в Redux.
  • Использование переменной state вводит в заблуждение. Уместнее было бы называть ее stateProxy, чтобы избежать возможной путаницы.

Пока что я остановился на таком подходе:

  • получаю state из прокси с помощью функции current из библиотеки @reduxjs/toolkit
  • на его основе получаю новый state, который и возвращаю (то есть, работаю с ним "по-старинке")

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

Приведенный выше пример выглядит так:

builder.addCase(fetchInitialData.pending, (stateProxy, _action) => { const state = current(stateProxy) return R.set(R.lensProp('fetchingStatus'), 'Fetching', state) })

Он получился более многословным, чем предыдущий вариант, но, при этом, и более однозначным.

Вот пример кода, когда нам нужно загрузить дополнительные данные, к уже существующим:

builder.addCase(fetchInitialData.fulfilled, (stateProxy, action) => { const state: AircompanyState = current(stateProxy) return R.pipe( R.always(state), R.mergeLeft(action.payload), R.set(R.lensProp('fetchingStatus'), 'Success') )() })

В дальнейшем попробую найти для Ramda более широкое применение и попробовать библиотеку fp-ts, которая вроде аналогичная, но имеет более качественную типизацию для работы с Typescript (в некоторых случаях, к сожалению, с Ramda не всегда удается обойтись без as).

Начать дискуссию