Зупніце синхронізацыю стану з useEffect: аднаковае для безпекі патэрнаў у React
Дазнаеце, чаму выкарыстоўванне useEffect для сінхронізацыя похаджанага стану спрычынае ситуаціі канкурэнціі і зайвыя перрадзіранні, а таксама як заменіць яго на вырахоўванне стану пад час перрадзірання і викорыстоўванне атрыбута key.
Як выкарыстоўванне useEffect як механізма для синхронізаціі стану спрычынае цыклы перрадароў, ситуаціі канкурэнцыі та глюкі ў інтэрфейсе.
На ваш стол падае запит на падтрымку з пазначкай «тэрпяча». Кліент кажа, што пад час пераключэння між акаунтамі на панелі керування командай лог актывацыяў іноды паказвае запісы, якія належаць акаунту, які ён пераглядаў трыдцать секунд раней.
Вы аналізуеце код. Тут няма нічага нестандартнага — ні слоя WebSocket, ні рабочых заданняў, толькі досыць стандартны відзор React з галоўнай і дэтальной частыняю.
Прабуючы ўсё на месцы, вы пераключаецеся з акаунта на акаунт. Першыя дзевяноста дзевяць пераключэнняў праходзяць нормальна. Але пад час стаго разу, калі ўвімкнута обмежэння шырокаспектра, візуальна ўтвараецца проблема: на короткі час раней выбранага корыстніка рэвізія паказваецца ў карточцы профіля новаўыбранага корыстніка, перш чым все вярнуцца на месца.
Календар з’явы гэтага эфекту — это патэрн, які выглядае абсалютна безнебяжным:
useEffect(() => {
if (selectedUserId) {
fetchUserData(selectedUserId).then((data) => {
setUserProfile(data);
});
}
}, [selectedUserId]);
Гэты адзін прыкмет — выкарыстоўванне useEffect для падтрымкі стану внутранняй компаненты ў супараднасці з параметрамі або іншым станам — спрычынае больш тонкіх багоў, візуальных проблем і структурных труднасцей у сучасным коде фронтэнда, чым практычна будзь-яны іншы патэрн.
1. Хыбна думка пра жыцёвы цикл: чаму разработчыкі выбіраюць useEffect
Калі ў React 16.8 з’явіліся хукі, інжынеры, якія мелі досвяд у роботе з класовымі компанентамі, часта спрыялі useEffect як замену для componentDidMount, componentDidUpdate і componentWillUnmount разам у ўсім адзіным API.
Гэтая падазра прывела да справжней плутання пасля таго.
Компаненты класов спадважалі імператывны стыль: калі зменялася проп, трэба было ручнае вызваць this.setState() унутроц componentDidUpdate, ўбачыць перасчыт усіх значэнняў, якія залежалі ад неё.
Пераходзячы да функцыйных компанентов, багато разработчыкаў продавялі тую ж імператывную прычыну, фактычна вырашаючы, што калі якая-небудзь проп зменяецца, іх задача — явна запісаць адпаведную змяну ў локальны стан.
Проблема тая, што React за сваёй сутнасцю є декларатывным і керуецца станам. Апісванне useEffect, якога ўжо едынственная мета — апдэйтаваць іншую локальную зменную стану, фактычна прымусвае React адрабатваць два цэлых процеса рендарування замест аднаго.
Ось парадокс, які выступае:
- React рэндаруе компанент, выкарыстоўваючы новыя пропы разам з яшчэ застарэлым станом.
setState().Возьмімо такі прыклад:
function UserBillingSummary({
plan,
addonCount
}: {
plan: string;
addonCount: number;
}) {
const [totalCost, setTotalCost] = useState(0);
useEffect(() => {
const base = plan === 'enterprise' ? 499 : 99;
setTotalCost(base + addonCount * 25);
}, [plan, addonCount]); return <div>Total: ${totalCost} / month</div>;
}
У даным случае зовсім няма патрэбы ў окремай зменнай стану — totalCost можа быць вычыслены абсалютна з plan і addonCount.
Іншымі словамі, компонент виканае непатрэбную дадатковую роботу проста для таго, каб досягнуць значэння, якое вже можна было вычысліць пад час першага рэндера.
У тым проміжным кадре корыстувачы можаюць на час побачыць неканонічныя цифры на экране. А якщо якая-небудзь логіка макета залежыць ад гэтаго вычысленага значэння, браузер таксама можа быць змушаны пераробіць макет і адрадасаваць зноў, чаго яму не трэба было робіць.
2. Эфект доміно: ланцюгі взаўзаемных залежнасцей
Гэты кост двойнай перрадарботы значная спадае, калі калькольванні пачынаюць залежаць ад рэзультатаў іншых калькольванняў.
Уявіце сабе панель фільтрацыі з калькольваннямі на калькольванні ў панелі аналізу:
function AnalyticsFilters({
organizationId
}: {
organizationId: string;
}) {
const [teams, setTeams] = useState<Team[]>([]);
const [selectedTeamId, setSelectedTeamId] = useState<string>('');
const [projects, setProjects] = useState<Project[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string>('');
useEffect(() => {
fetchTeams(organizationId).then((res) => {
setTeams(res);
setSelectedTeamId(res[0]?.id || '');
});
}, [organizationId]); useEffect(() => {
if (selectedTeamId) {
fetchProjects(selectedTeamId).then((res) => {
setProjects(res);
setSelectedProjectId(res[0]?.id || '');
});
}
}, [selectedTeamId]); useEffect(() => {
if (selectedProjectId) {
logAnalyticsFilterChange(selectedProjectId);
}
}, [selectedProjectId]); return (
<div className="filter-bar">
{/* Filter UI */}
</div>
);
}
Пазірце, што адбываецца ў момент змены organizationId:
- React перрадарбатоўваеся, выкарыстоўваючы новы
organizationId. - Першае калькольванне запрашвае спіс команд, а потым апдэйтуе як
teams, так іselectedTeamId. - React зноў перрадарбатоўваеся.
- Другае калькольванне зазначае апдэйтаваны
selectedTeamIdі запрашвае супаўзелічаныя проекты. - React зноў перрадарбатоўваеся.
- Трэцяе калькольванне зафіксавае новы
selectedProjectIdі запішвае змену.
Тое, што спачатку было апдэйтом адной пропы, тепер ператворылася на ланцоўку змян стаю і выконання калькольванняў.
Калі застосунак расте, такі ланцюгі стаюць справды складнымі для адстэплення. Якщо адказы сеті прыходзяць не па порядку, або якщо адны з іх вяртаецца порожнім у зв’язку з проблемамі з правамі або іншымя экстремальнымі ситуацыямі, інтэрфейс можа западзець у нестабільны стан без выяўлення якога-лібо очвістага бягу.
Асалідныя проблемы — це не толькі дадзенне дапаможнага лічэння, але і тое, што компонент таямна ператварыўся на мініятурную асінхронную машыну станоў, якую ніхто спецыяльна не праектаваў як такую.
3. Прывид асінхронных умоваў канкурэнцыі
Неконтрольваныя асінхронныя вызовы ўнутры useEffect — яшчэ адна частая прычына з’яўлення фантомных дадзеных у застосунках з адной сторанай.
Уявіце сабе агента падтрымкі, який швытаюча клацае па разных рядках табелі з заявкамі:
function TicketDetailView({
ticketId
}: {
ticketId: string;
}) {
const [ticket, setTicket] = useState<TicketData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true); api.getTicket(ticketId).then((data) => {
setTicket(data);
setLoading(false);
});
}, [ticketId]); if (loading) return <div>Loading ticket...</div>; return <TicketDetails ticket={ticket} />;
}
Ось последовасць падзеяў, якая нарабляе проблемы з этым компонентам:
- Агент нажымае на квітанцію №101. Актуецца запит A.
- Перш чым запит A будзе вырашаны, агент нажымае на квітанцію №102. Актуецца запит B.
- Запит B вырашаны першы, таму ў інтерфейсе тепер паказываецца квітанція №102.
- Нарэшце запит A вырашаны і вызываецца
setTicket(ticket101). - У бокавай панелі яшчэ выдзеляецца квітанція №102, але у вікні з деталямі тепер паказываецца квітанція №101.
У гэтым случае прычына не ў React. Аслівныя проблемы таму, што компонент дазволяе застарэлым, некоректным запитам перазапісваць стан пасля таго, як корыстнік вже перайшоў іншыя месцы.
Якщо вы запрашываеце даныя ўнутрь эфекту без якіх-леба дапаможных бібліятэкаў, вам трэба захістіцца ад гэтага праз ручную чыстку:
useEffect(() => {
let isCurrent = true;
setLoading(true); api.getTicket(ticketId)
.then((data) => {
if (isCurrent) {
setTicket(data);
setLoading(false);
}
})
.catch((err) => {
if (isCurrent) {
handleTicketError(err);
}
}); return () => {
isCurrent = false;
};
}, [ticketId]);
Лепшы варыянт, калі ваш кліент API яго падтрымле, — это AbortController. У замест на простае адкінуванне запознелай адпаведзі, вы фактычна анулюеце запускаемы кансэлкаванне перш, чым яно зможа будзе завершыцца.
4. Чыстая альтэрнатыва: выведаны стан пад час відраслівання
Частаўсе простыяшы спосаб выправлення багоў сінхронізацыі — гэта увогуле ўтримацца ад сінхронізацыі стану.
У багатых случаях, калі разработчыкі інстынктыўна вяртаюцца да useState у поўнай супарадзе з useEffect, значэнне, якое яны намагаюцца зберагчы, вялікай часткай уже існуе ў дакументах або стане родзіча.
У замест на дуплікацыю гэтага значэння ў локальны стан, вы можете проста вырахаваць яго безпосередна пад час відраслівання.
Ось проблематычны шаблон:
function OrderSummary({
items,
discountCode
}: OrderSummaryProps) {
const [discountPercent, setDiscountPercent] = useState(0);
const [subtotal, setSubtotal] = useState(0);
const [finalTotal, setFinalTotal] = useState(0);
useEffect(() => {
const rawSum = items.reduce(
(acc, item) => acc + item.price * item.quantity,
0
); setSubtotal(rawSum);
}, [items]); useEffect(() => {
const discount = calculateDiscount(discountCode);
setDiscountPercent(discount);
}, [discountCode]); useEffect(() => {
setFinalTotal(
subtotal - subtotal * (discountPercent / 100)
);
}, [subtotal, discountPercent]); return (
<SummaryView
subtotal={subtotal}
total={finalTotal}
/>
);
}
Теперы парабяльце гэта з версіяй, створанай на аднойчы выведаным стане:
function OrderSummary({
items,
discountCode
}: OrderSummaryProps) {
const subtotal = items.reduce(
(acc, item) => acc + item.price * item.quantity,
0
);
const discountPercent = calculateDiscount(discountCode); const finalTotal =
subtotal - subtotal * (discountPercent / 100); return (
<SummaryView
subtotal={subtotal}
total={finalTotal}
/>
);
}
Контраст мае значэнне. Не існуе дублікатных станоў, няма механізму, які б падтрымалі синхроннасць значэнняў, і няма вікна, у яком finalTotal могла бы адхіліцца ад сумы subtotal і discountPercent.
Самыя пропсы застаюцца ўсёвышэй едынай адпаведнаючай інформацыяй.
Калі вырахунак справа ўскладнены, useMemo дазволяе зберагаць рэзультат у кешы між перрадамі:
const filteredTransactions = useMemo(() => {
return rawTransactions.filter((tx) => {
return (
tx.amount >= minThreshold &&
tx.category === activeCategory
);
});
}, [rawTransactions, minThreshold, activeCategory]);
Ключовы момант закладаецца ў тым, што useMemo існуе выключна для мемаізавання вырахунку — ён не прызначаны для падтрымкі синхроннасці двух аддзеленых станоў.
5. Абявочнае скасаванне стану за дапамогою пропсы key
Адпаведная пастка выступае, калі форма для рэдагавання патрэбна скасаваць своія поля кожны раз, калі зменяецца об’ект, які рэдагуецца.
Тыповая інстынктыўная дзеяльнасць выглядае так:
function EditUserModal({
user
}: {
user: UserData;
}) {
const [name, setName] = useState(user.name);
const [role, setRole] = useState(user.role);
useEffect(() => {
setName(user.name);
setRole(user.role);
}, [user.id]); return (
<form>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/> <select
value={role}
onChange={(e) => setRole(e.target.value)}
/>
</form>
);
}
Такой падчынны компонент можа стварыць видны момант перамены, калі даныя пакульнай запісі застаюцца на экране пры аднае перрадарэбніце, прычыму эфект не выконваецца і данні не апдэюцца.
Што ўжо горш, як толькі прыбудуць новыя даны пад час рэдагавання, гэты падчынны компонент можа без жадных сігналоў заменіць тое, што корыстнік пісаў.
У React уже ёсць вбудована, декларатыўная рашэння для гэтага: атрыбут key.
У параднікавым компоненте:
function UserAdminPage() {
const [selectedUser, setSelectedUser] =
useState<UserData | null>(null);
return (
<div>
<UserList onSelectUser={setSelectedUser} /> {selectedUser && (
<EditUserForm
key={selectedUser.id}
initialUser={selectedUser}
/>
)}
</div>
);
}
Тады локальны стан падчыннага компонента застаецца простым, без нічога, што трэба сінхронізаваць:
function EditUserForm({
initialUser
}: {
initialUser: UserData;
}) {
const [name, setName] = useState(initialUser.name);
const [role, setRole] = useState(initialUser.role);
return (
<form>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/> <select
value={role}
onChange={(e) => setRole(e.target.value)}
/>
</form>
);
}
Калі значэнне key зменяецца з user-1 на user-2, React не прабуе апдэюваць існуючы падчынны компонент — ён абракцэюе яго і стварае абсалютна новы экземпляр, стан якога ініціялізуецца на базе новых даных корыстніка.
Зовсім не трэба ніякога эфекту сінхронізацыі.
6. Дзе насправды пацелуецца код: працоўнікі здачы запускаў vs. эфекты
Прыдатны ментальны модэль такі: эфекты існуюць для таго, каб ваш компонент быў сінхронізаваны з чымось за межами React, тады як працоўнікі здачы запускаў існуюць для реагавання на дзеянне корыстніка.
Уявіце, што вам трэба запускнуць заход з аналітыки і паказаць паведамленне пра пאўтарную перакананнę кожны раз, калі хтось нажме «Адправіць замовленне».
Одны з спосабаў напісаць гэта:
function CheckoutButton({
orderId
}: {
orderId: string;
}) {
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (submitted) {
analytics.track('order_submitted', {
orderId
}); showToast('Order placed successfully!');
}
}, [submitted, orderId]); return (
<button onClick={() => setSubmitted(true)}>
Place Order
</button>
);
}
Проблема ў тым, што гэта раздзеляе трыгер і дзеянне — нажыманне ставіць флаг, а эфект реагуе на гэты флаг пазней.
Чыстыяй спосаб прыводзіць усё ўнутрь самага працоўніка здачы запускаў:
function CheckoutButton({
orderId
}: {
orderId: string;
}) {
const handlePlaceOrder = async () => {
await submitOrderApi(orderId);
analytics.track('order_submitted', {
orderId
}); showToast('Order placed successfully!');
}; return (
<button onClick={handlePlaceOrder}>
Place Order
</button>
);
}
Тепер прычына-наследкі ўсунутая: корыстнік нажымае, заявка адправляецца, а наступныя дзеянні выконваюцца негайна як частка таго ж здарэння. Не існуе проміжных змян стану, якія можна было б выявіць і на якія можна было б адказаць.
7. Калі useEffect насправды ўсунутая?
Нічга з гэтага не значыць, што сам useEffect ў сабе є бяспрыцэзным.
Проблемы пачынаюцца, калі яго спрыяжаюць як універсальны інструмент для перадачы данных между разнымі часткамі стану React.
Яго асалоджэнне — падтрымліваць синхроннасць вашага компонента з чымось, што знаходзіцца за межами самой моделі адрасавання React.
Этот “чысьто за межамі React” зазвычай належыць да такіх катэгорый:
- Натыўныя API прыгледача, такія як
window.addEventListener,IntersectionObserverабоmatchMedia
document.titleАдстэйканне шыроўні вікна браузера пад час змены яго размеру — гарны прыклад легітымнага эфекту:
function useWindowWidth() {
const [width, setWidth] = useState(
() => window.innerWidth
);
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth);
}; window.addEventListener(
'resize',
handleResize
); return () => {
window.removeEventListener(
'resize',
handleResize
);
};
}, []); return width;
}
У гэтым случае эфект заключаецца ў выкананні чагосьці, чаго не можа здзейсніць сама рэндарынацыя: ствараецца падпіска на браузерскія запускі і ёна анулюецца пад час чысткі.
Гэта самэ тое, для чаго і быў створаны useEffect.
Архітэктурныя правілы, якія я зараз дазваляю
Калі панелі керування або прыкладныя програмы на базе React пачынаюць працаваць повольна, нестабільна або страждаюць ад багоў, зв’язаных з часам выконання, якія важка адтворыць, перша рэч, яку варта пераглянуць, — гэта тое, як useEffect выкарыстоўваецца ў всім кодзе.
Чатыры каналізуючыя прынцыпы дапамагаюць рашыць большасць проблем.
1. Вырахоўваць значэнні пад час атрыбутавання.
Усё, што можна вырахаваць з пропаў або існуючага стану, трэба выраховваць безпасова ў самым тэле атрыбутавання. Вжываць useMemo трэба толькі тады, калі такі вырахунак даслівна ўскладнены.
2. Размешчаць логіку, кія запускаецца па дзействію корыстніка, ў працэсарах змаганняў. Калі ўтварыцца якая-небудзь ситуацыя ў результате кліка, натыкання на клавішу, выбору або адправкі, такая логіка павінна знаходзіцца пры самым змаганні, якое ёё спрычынала, а не бываць розсеянай ў окремым эфекте.
3. Чыста перазначаць стан за дапамогою пропа key.
Калі зміняецца аб’ект, трэба стварыць абсалютна новы экземпляр компонента; нехай React пераз’яўляе компонент, а не ручна сінхронізуе кожны окремы поль.
4. Адзінавайце useEffect для справжняй звяроўкі з зовнішнім светам.
Падзеяй браузера, праграмы-підписчыкі, з’ўязкі WebSocket і інтеграцыі з бібліятэкамі ў формате імператыўных кодаў — гэта правільныя сферы прыменення эфектаў.
Мета не ў тым, каб цэлком пазбавіцца useEffect у сваёй базе коду.
Мета — перастаць викорыстоўваць яго як неформальную схему для перадачы значэнняў між разнымі часткамі стану React.
Калі вырахаваныя значэння будуць спрыягвацца як вырахаваныя, дзеянні корыстувальця будуць обрабляцца як падзеі, а толькі справжнія зовнішнія системы будуць падключаныя через эфекты, компоненты React стануць значна простэйшымі для розумення.
А большая частка тых загадковых багоў, якія прыходзяць на вочы толькі пасля дзесяткаў клікаў, пад медленным з’ўязкам інтэрнету або толькі ў режыме продакшна, стануць набагато лёгкімі да запобежэння з самага пачатку.
Спаднія матэрыялы
- Стварэнне ментальнага модэля для React: супрацоўка даных, стан і хукі — Дзеянне пра прычыны, якія стояць за асновнымі канцэптамі React — супрацоўкай даных, компонентамі, пропамі, станом і хукамі — каб развіць інтуўіцыю заместа запамятоввання API.
- Набор кастамных хукіў, якія можна викорыстоўваць знову, для кожнага новага проекту на React — Пазнакоміцеся з падобраным наборам кастамных хукіў для React, якія включаюць функцыі для зберагчэння даных, адкладання выконання задач, обработкі клікаў і запрашэння даных, і якія памагаюць усунуць павтаральны код у новых проектах.