На своем проекте я столкнулся с ситуацией, когда декларативная модель React начинает конфликтовать с дизайном.
Мы даем пользователю возможность загружать сразу несколько изображений, но для каждого загруженного изображения просим заполнить форму с дополнительными параметрами. По дизайну это модалка с одной формой и кнопкой сохранения под модалкой. После успешного сохранения форма внутри модалки должна красиво слайдится на следующую, а кнопка сохранения так и остается висеть на своем месте, но уже должна влиять и соответствовать статусу следующей формы.
То есть компонентно есть такая иерархия:
<ImagesUploadField />
<Modal>
<ModalContent>
<Slider>
{images.map(image =>
(<Slide>
<ImageForm image={image} />
</Slide>)
)}
</Slider>
</ModalContent>
<ModalButtons>
<SaveButton />
</ModalButtons>
</Modal>
И тут появляется конфликт - форма находится в одном месте, а связанная с ней кнопка в другом. Да еще и в разный момент времени кнопка связана с разными формами. К тому же она должна получать статус актуальной формы, чтобы отображать процесс ожидания при отправке формы.
Я бы мог пробрасывать состояние через контекст, но такой подход плохо читается - все-таки контекст удобнее использовать для более глобальных вещей вроде локализации или статуса авторизации. Наверное еще можно было бы сделать один объект формы и переиспользовать его, но смешивание разных данных в одной форме чревато неожиданным поведением и противоречит иерархии. Можно еще пойти к дизайнеру и договориться сделать кнопку внутри формы, но это было бы против нашей дизайн системы и делать модалку, которая визуально отличается от других, тоже не кажется правильным.
На помощь пришли ref и Map. Идея в том, что каждый ImageForm будет экспортировать ссылку на свой объект формы через useImperativeHandle, а родительский компонент будет добавлять её в общий Map, который в свою очередь будет хранится в ref, чтобы не терять все это между рендерам. То есть мы осознанно нарушаем привычный для React data-flow и поднимаем ссылку наверх.
Код буде выглядеть примерно так:
// Форма отдельного изображения
const ImageFormWithRef = forwardRef((props, ref) => {
// Мы на проекте используем formik, но тут может
// быть и другой объект формы. Но важно что useFormik
// возвращает стабильный объект формы, который не
// обновляется между рендерами
const form = useFormik({
initialValues: getInitialValues(props.image),
onSubmit: getSubmitAction(props.onSuccess),
})
// Ключевое место в подходе: мы передаем в ref объект
// формы, чтобы управлять ей снаружи
useImperativeHandle(ref, () => form);
return <ImageForm form={form} />
})
ImageFormWithRef.displayName = 'ImageFormWithRef';
// Как эту форму использовать
const formRefs = useRef(new Map());
const [, forceRerender] = useReducer((x: number) => x + 1, 0);
<ImageFormWithRef
image={image}
ref={(form) => {
// Записываем текущий объект формы в Map
formRefs.current.set(image.id, form);
// Поскольку изменения `ref.current` не вызывают ререндер,
// а нам нужно, чтобы кнопка реагировала на смену формы,
// приходится вручную инициировать обновление компонента
// через небольшой хак с useReducer
forceRerender();
}}
onSuccess={() => {
// Здесь логика перехода к другому слайду
...
}}
/>
// Получаем текущую форму, чтобы привязать её к кнопке сохранения
const currentImage = images[selectedImageIndex];
const currentForm = currentImage
? formRefs.current.get(currentImage.id)
: undefined;
<SaveButton
onClick={() => {
// Получаем у SaveButton все необходимые состояние и метод на сохранение формы
currentForm?.submitForm();
}}
isDisabled={currentForm == null || currentForm.isSubmitting}
isLoading={currentForm?.isSubmitting}
/>
Это неидеальное решение, так как требует откровенного хака с forceRerender, но оно позволяет сохранить ожидаемую в этом месте иерархию компонентов и читаемость кода. Кажется что это допустимо, пока компоненты родителя и форм с ref-ом находятся в рамках одного модуля.