React.js. Почему функция-редюсер вызывается дважды
17.09.2021
Теги: Frontend • Hook • JavaScript • React.js • Web-разработка • Компонент • Практика • Теория • Функция
Небольшое приложение — телефонная книга, не имеет практической ценности, сделано исключительно с целью изучения React. Изначально вообще не планировал публиковать это, но столкнулся с интересным случаем, когда функция-редюсер вызывалась дважды для одного action
. Несколько часов не мог понять — что вообще происходит? Так что решил записать, чтобы не наступать на эти грабли дважды.
import { ContactProvider } from './ContactContext'; import { ContactGrid } from './ContactGrid'; import { ContactMenu } from './ContactMenu'; import { ContactForm } from './ContactForm'; export default function ContactBook() { return ( <ContactProvider> <h1>CONTACT BOOK</h1> <ContactMenu /> <ContactGrid /> <ContactForm /> </ContactProvider> ); }
Данные телефонной книги и функцию-редюсер для изменения состояния мы храним в контексте, чтобы все компоненты ниже <ContactProvider>
имели к ним доступ через кастомный хук useContact
.
import { createContext, useContext, useReducer } from 'react'; import { v4 as uuidv4 } from 'uuid'; const initState = { contacts: [ { id: uuidv4(), firstName: 'John', lastName: 'Smith', phone: '555-123-123', }, { id: uuidv4(), firstName: 'Jane', lastName: 'Smith', phone: '555-123-123', }, { id: uuidv4(), firstName: 'Jack', lastName: 'Sparrow', phone: '555-555-444', }, { id: uuidv4(), firstName: 'Fluffy', lastName: 'White', phone: '555-678-910', }, ], // id контакта, который выделен и будет удален или отредактирован selected: null, // признак того, что книга переключена в режим редактирования editmode: false, }; export const Types = { APPEND: 'APPEND', SELECT: 'SELECT', EDITMD: 'EDTMOD', UPDATE: 'UPDATE', REMOVE: 'REMOVE', ROLLBACK: 'ROLLBACK', }; let backup = []; function contactReducer(state, action) { switch (action.type) { case Types.APPEND: // добавить новый контакт const contact = {id: uuidv4(), ...action.payload} return { ...state, contacts: [...state.contacts, contact], }; case Types.SELECT: // выделить контакт для редактирования или удаления return state.editmode ? { ...state, selected: action.payload.id, } : state; case Types.EDTMOD: // включить-выключить режим редактирования // будем сохранять backup перед редактированием или удалением backup = !state.editmode ? [] : backup; const selected = (!state.editmode && !state.selected && state.contacts.length) ? (state.contacts[0].id) : (state.selected); return { ...state, editmode: !state.editmode, selected: selected, }; case Types.UPDATE: // обновить отредактированный контакт if (!state.selected) return state; backup.push(JSON.stringify(state.contacts)); // выполянем backup const index = state.contacts.findIndex(item => item.id === state.selected); if (index < 0) return state; const arrayCopy = [...state.contacts]; arrayCopy.splice(index, 1, {id: state.selected, ...action.payload}); return { ...state, contacts: arrayCopy, }; case Types.REMOVE: // удалить выделенный контакт if (!state.selected) return state; backup.push(JSON.stringify(state.contacts)); // выполянем backup const contacts = state.contacts.filter(item => item.id !== state.selected); return { ...state, contacts: contacts, selected: contacts.length ? contacts[0].id : null, }; case Types.ROLLBACK: // восстановить записную книжку из резервной копии return backup.length ? { ...state, contacts: JSON.parse(backup.pop()), selected: null, } : state; default: console.log(`Action type ${action.type} was not recognized`); return state; } } const ContactContext = createContext(); export function ContactProvider(props) { const [state, dispatch] = useReducer(contactReducer, initState); return <ContactContext.Provider value={[state, dispatch]} {...props} />; } export function useContact() { const context = useContext(ContactContext); if (!context) { throw new Error('Contact context can be accessed only under ContactProvider'); } return context; }
Компонент ContactMenu
отвечает за верхнее меню телефонной книги:
import { useContact, Types } from './ContactContext'; export function ContactMenu() { const [state, dispatch] = useContact(); const { editmode } = state; const changeEditMode = () => { dispatch({ type: Types.EDTMOD, }); }; const removeSelected = () => { dispatch({ type: Types.REMOVE, }); }; const rollbackChanges = () => { dispatch({ type: Types.ROLLBACK, }); }; const buttonStyle = { margin: 10, padding: 10, backgroundColor: '#FFF', border: '1px solid #333', borderRadius: 6, }; const removeButtonStyle = {...buttonStyle}; const rollbackButtonStyle = {...buttonStyle}; const onoffButtonStyle = {...buttonStyle}; if (editmode) { onoffButtonStyle.backgroundColor = '#EFE'; } return ( <div> <button style={onoffButtonStyle} onClick={changeEditMode}> EDIT MODE (ON|OFF) </button> {editmode ? ( <> <button style={removeButtonStyle} onClick={removeSelected}> REMOVE SELECTED </button> <button style={rollbackButtonStyle} onClick={rollbackChanges}> ROLLBACK CHANGES </button> </> ) : ( null )} </div> ); }
Компонент ContactGrid
отвечает за показ всех контактов книги:
import { useContact, Types } from './ContactContext'; import './ContactGrid.css'; function ContactItem({ contact, selected, editmode, onClick }) { const { firstName, lastName, phone } = contact; const src = `https://cataas.com/cat?${firstName}${lastName}`; const className = `contact-item ${selected && editmode ? 'contact-selected' : ''}`; return ( <div className={className} onClick={onClick}> <img src={src} alt="Contact avatar" /> <div>{firstName} {lastName}</div> <div>{phone}</div> </div> ); } export function ContactGrid() { const [state, dispatch] = useContact(); const { contacts, selected, editmode } = state; const selectContact = (id) => { if (editmode) { dispatch({ type: Types.SELECT, payload: { id }, }); } }; return ( <div className="contact-grid"> {contacts.map((contact) => ( <ContactItem key={contact.id} contact={contact} selected={selected === contact.id} editmode={selected === contact.id && editmode} onClick={() => selectContact(contact.id)} /> ))} </div> ); }
Компонент ContactForm
позволяет изменять и добавлять контакты:
import { useEffect, useState, useRef } from 'react'; import { useContact, Types } from './ContactContext'; const emptyLocalState = { firstName: '', lastName: '', phone: '', } export function ContactForm() { const [state, dispatch] = useContact(); const { contacts, selected, editmode } = state; const [localState, setLocalState] = useState(emptyLocalState); const inputFirstRef = useRef(); const inputLastRef = useRef(); const inputPhoneRef = useRef(); // при переходе в режим редактирования и выделении какого-нибудь контакта — // мы должны заполнить поля формы данными этого контакта для редактирования useEffect(() => { if (selected && editmode) { // изменение контакта const contact = contacts.find(item => item.id === selected); if (contact) { const copy = {...contact}; delete copy.id; setLocalState(copy); } } else { // добавление контакта setLocalState(emptyLocalState); } // eslint-disable-next-line }, [selected, editmode]); // когда поле ввода пустое — делаем рамку поля красным, что надо заполнить useEffect(() => { if (localState.firstName.trim() === '') { inputFirstRef.current.style.borderColor = '#F00'; } else { inputFirstRef.current.style.borderColor = '#333'; } if (localState.lastName.trim() === '') { inputLastRef.current.style.borderColor = '#F00'; } else { inputLastRef.current.style.borderColor = '#333'; } if (localState.phone.trim() === '') { inputPhoneRef.current.style.borderColor = '#F00'; } else { inputPhoneRef.current.style.borderColor = '#333'; } }, [localState]); const handleChange = (event) => { setLocalState({ ...localState, [event.target.name]: event.target.value }); }; const handleSubmit = (event) => { event.preventDefault(); if (hasError()) return; if (selected && editmode) { // изменение контакта dispatch({ type: Types.UPDATE, payload: localState, }); } else { // добавление контакта dispatch({ type: Types.APPEND, payload: localState, }); setLocalState(emptyLocalState); } } const hasError = () => { let error = false; if (localState.firstName.trim() === '') { error = true; } if (localState.lastName.trim() === '') { error = true; } if (localState.phone.trim() === '') { error = true; } return error; } const inputStyle = { margin: 10, padding: 10, backgroundColor: '#FFE', border: '1px solid #333', borderRadius: 6, }; if (editmode) { inputStyle.backgroundColor = '#EFE'; } return ( <form onSubmit={handleSubmit}> {selected && editmode ? ( <h4>CHANGE CONTACT</h4> ) : ( <h4>APPEND CONTACT</h4> )} <input type="text" name="firstName" value={localState.firstName} onChange={handleChange} placeholder="First name" style={inputStyle} ref={inputFirstRef} /> <input type="text" name="lastName" value={localState.lastName} onChange={handleChange} placeholder="Last name" style={inputStyle} ref={inputLastRef} /> <input type="text" name="phone" value={localState.phone} onChange={handleChange} placeholder="Phone" style={inputStyle} ref={inputPhoneRef} /> <input type="submit" style={inputStyle} value="SAVE CONTACT" /> </form> ) }
Компонент ContactForm
— управляемый, то есть имеет локальное состояние и заполняет поля формы для добавления-редактирования контакта из состояния.
У контактной книги есть два режима — просмотра контактов и добавления новых + режим редактирования. В режиме редактирования можно изменять и удалять контакты. Поскольку это потенциально опасный режим — перед сохранением и перед удалением контакта вся записная книжка записывается в массив backup
. Сколько раз нажимаются кнопки «SAVE CONTACT» и «REMOVE SELECTED» — столько раз в backup
записывается новый элемент. При входе в режим редактирования массив backup
обнуляется — другими словами, откатить можно только одну сессию — и до момента выхода из режима редактирования.
А теперь собственно то, из-за чего и решил написать эту заметку — восстановление удаленных контактов происходило как-то странно. После удаления всех контактов и последовательного восстановления — в какой-то момент один шаг пропадал. То есть, нажал кнопку «ROLLBACK CHANGES» — восстановился один контакт, еще раз нажал «ROLLBACK CHANGES» — восстановились три контакта, а не два. Установил точку останова на ROLLBACK
— при одном нажатии «ROLLBACK CHANGES» произошло два останова.
После долгих поисков в интернете нашел вот это обсуждение. Оказывается, функция-редюсер вызывается дважды, причем второй раз — со старым значением state
. Таким образом в режиме разработки проверяется чистота функции-редюсера. При этом обнаружить двойной вызов функции-редюсера не так просто — если добавить в начало функции console.log()
, то это отработает только один раз, потому что React изменяет методы консоли при втором вызове.
Если выполнить сборку проекта и запустить приложение, то все работает правильно. Другими словами, на production-сервере двойного вызова редюсера не будет, это только в режиме разработки. Вот такой способ обратить внимание разработчика, что код нуждается в доработке — как в моем случае.
> npm run build > serve -s build
Если убрать <React.StrictMode>
— то в режиме разработки все будет, как на production-сервере — функция-редюсер вызывается один раз. То есть <React.StrictMode>
позволяет обнаружить ошибки на этапе разработки, чтобы они не попали на production-сервер.
Поиск: Hook • JavaScript • React.js • Web-разработка • Frontend • Компонент • Практика • Теория • Функция