React.js. Почему функция-редюсер вызывается дважды

17.09.2021

Теги: FrontendHookJavaScriptReact.jsWeb-разработкаКомпонентПрактикаТеорияФункция

Небольшое приложение — телефонная книга, не имеет практической ценности, сделано исключительно с целью изучения 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 • Компонент • Практика • Теория • Функция

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.