Практика: ReviewMe

Задача: провести ревью, указать неточности и что можно было бы исправить.
// Указать неточности и то что можно было бы исправить. import React from "react"; const pleaseReviewMe = () => { const [count, setCount] = React.useState(1); const [items, setItems] = React.useState([{ id: 1 }]); React.useLayoutEffect(() => { setInterval(() => console.log(count), 1000); }); const click = React.useCallback(() => { setCount(count + 1); setItems([...items, { id: count + 1 }]); } ); return ( <React.Fragment> <ul> {items.map((item) => ( <li>{item.id}</li> ))} </ul> <button onClick={() => click()}>add one</button> </React.Fragment> ); }; export default PleaseReviewMe;
 
 
🎯 Зачем спрашивают
  • Проверить навыки ревью кода
  • Узнать, способен ли кандидат аргументировать свою точку зрения
 
📝 Ответ
Коротко:
!!! Внимательно читайте код !!!
 
Подобный тип задач позволяют проверить ваши навыки в действии, ведь на работе вы много кода как пишете, так и ревьюите. И через подобный род задач легко понять, знаете/понимаете эвристики написания хорошого кода.
 
На реальном собесе вам может попасться любая вариация данной задачи.
 
Вот чек-лист, который стоит держать в голове:
  • useLayoutEffect vs useEffect. У хука useLayoutEffect специфичная задача — вызваться до рендера. Если это необходимо, используем его. В остальных случаях — useEffect
  • Хук useCallback должен использоваться в паре с memo
  • Есть подписка — должна быть и отписка (setInterval, setTimeout, addEventListener)
  • Использование уникальных значений для атрибута key в элементах списка
  • Не создаются лишние функции () => click()
 
Спойлер решения
// Указать неточности и то что можно было бы исправить. import { useEffect, useState } from "react" const PleaseReviewMe = () => { const [items, setItems] = useState([{ id: 1 }]); useEffect(() => { const intervalId = setInterval(() => console.log(items.length), 1000); return () => { clearInterval(intervalId) } }, [items]); const click = () => { setItems([...items, { id: items.length + 1 }]); }; return ( <React.Fragment> <ul> {items.map((item) => ( <li key={item.id}>{item.id}</li> ))} </ul> <button onClick={click}>add one</button> </React.Fragment> ); }; export default PleaseReviewMe;
Подробнее:
Пробежимся по каждому пункту отдельно.
 
const pleaseReviewMe = () => ...
В комьюнити принято, что компоненты именуются с большой буквы. В реальности данную ошибку бы отловил линтер, тут же — тест на внимательность.
 

 
React.useState
Это субъективная правка, можно использовать API реакта так, либо использовать деструктуризированный импорт. Второе решение делает код более читаемым.
// Указать неточности и то что можно было бы исправить. import { useEffect, useState } from "react" const pleaseReviewMe = () => { const [count, setCount] = useState(1); const [items, setItems] = useState([{ id: 1 }]); useLayoutEffect(() => { setInterval(() => console.log(count), 1000); }); const click = useCallback(() => { setCount(count + 1); setItems([...items, { id: count + 1 }]); } ); return ( <React.Fragment> <ul> {items.map((item) => ( <li>{item.id}</li> ))} </ul> <button onClick={() => click()}>add one</button> </React.Fragment> ); }; export default PleaseReviewMe;
 

 
useLayoutEffect(() => { setInterval(() => console.log(count), 1000); });
Во-первых, не ясна причина, по которой был взят именно useLayoutEffect. Данный хук синхронный и блокирует отрисовку.
Данный хук полезен, когда нужно произвести вычисления или какие-то манипуляции перед тем, как DOM обновится. Пока не доказано обратное, лучше использовать useEffect.
💡
Как пример: расчет координат для компонента Tooltip. Сперва в useLayoutEffect высчитываются координаты, куда следует воткнуть компонент, а затем координаты устанавливаются в компонент и он рендерится.
 
useEffect(() => { setInterval(() => console.log(count), 1000); });
Во-вторых, в хуке отсутствуют зависимости, что приводит к лишним вызовам.
useEffect(() => { setInterval(() => console.log(count), 1000); }, [count]);
 
В-третьих, в хуке есть создание “подписки”, но нет “отписки”. Это напрямую может влиять на перфоманс. Поэтому, если вы используете setInterval, setTimeout, addEventListener, то не забывайте и отписываться.
useEffect(() => { const intervalId = setInterval(() => console.log(count), 1000); return () => { clearInterval(intervalId) } }, [count]);
 
💡
Для fetch запросов это правило так же действует, только запрос нужно отменять с помощью AbortController
 

 
// ... const click = useCallback(() => { setCount(count + 1); setItems([...items, { id: count + 1 }]); }); return ( <React.Fragment> <button onClick={click}>add one</button> </React.Fragment> ); };
Создается лишняя функция-обертка. Она ни к чему, тратятся лишняя память при каждом рендере.
 

// ... const click = useCallback(() => { setCount(count + 1); setItems([...items, { id: count + 1 }]); }); return ( <React.Fragment> <button onClick={click}>add one</button> </React.Fragment> ); };
Во-первых, в useCallback не передан массив зависимостей и мемоизация будет работать вхолостую. Хук useCallback нужен для оптимизации рендера. Он мемоизирует ссылку на функцию по зависимостям
// Псевдокод подкапотной реализации хука useCallback // Представим, что мы мемоизируем функцию сложения двух чисел // ... // { // '1,1': () => ..., // '2,4': () => ..., // } // ...
Сейчас это не будет работать, в useCallback не передан массив зависимостей. То есть хук каждый раз сохраняет новую ссылка — лишняя работа и лишняя затраченная память.
 
// ... const click = useCallback(() => { setCount(count + 1); setItems([...items, { id: count + 1 }]); }, [count, items]); return ( <React.Fragment> <button onClick={click}>add one</button> </React.Fragment> ); };
Во-вторых, чтобы мемоизация заработала, данный хук следует использовать в паре с memo функцией. В memo следует оборачивать React компоненты, чтобы включить механизм жадной сверки пропсов. Это позволит компоненту перерендериваться компоненту только в том случае, если изменились пропсы.
В нашем примере функция передается не react компоненту, а react элементу. Его можно обернуть в memo, только если вынести в отдельный компонент. В данном случае, это избыточно. Получается, что useCallback бесполезен в данном случае.
// ... const click = () => { setCount(count + 1); setItems([...items, { id: count + 1 }]); }); return ( <React.Fragment> <button onClick={click}>add one</button> </React.Fragment> ); };
 

 
<ul> {items.map((item) => ( <li>{item.id}</li> ))} </ul>;
Для элементов массивов желательно назначать атрибут key с уникальным значением. Почему это важно можно почитать:
 
<ul> {items.map((item) => ( <li key={item.id}>{item.id}</li> ))} </ul>;
 

 
 
⚖️ Компромиссы
 
🔎 Встречные вопросы
  • Когда есть смысл использовать один интервал + ref вместо интервала, зависещего от состояния (пересоздание)?
  • Когда оправдано использование useLayoutEffect?
 
🚩 Красные флаги
  • useEffect на всё подряд, потому что “быстрее”.
  • useCallback везде, без понимания реф.равенства
  • Подписка без очистки
  • ключи из индекса
 
🛠 Практика
Задача 1
import React, { createContext, useEffect, useState } from "react"; export const UserContext = createContext(); export const UserProvider = ({ children }) => { const [user, setUser] = useState({ name: "Anonymous" }); const [theme, setTheme] = useState("light"); useEffect(() => { console.log("Re-render provider"); }); const updateUser = (name) => { setUser({ name }); if (name === "admin") { setTheme("dark"); } }; return ( <UserContext.Provider value={{ user, theme, updateUser }}> {children} </UserContext.Provider> ); };
Задача 2
import React, { useEffect, useState } from "react"; const UserStats = ({ id }) => { const [user, setUser] = useState(null); const [stats, setStats] = useState(null); useEffect(() => { fetch(`/api/user/${id}`).then((r) => r.json()).then(setUser); }, [id]); useEffect(() => { if (user) { fetch(`/api/stats/${user.name}`).then((r) => r.json()).then(setStats); } }, [user]); return ( <div> <p>{user?.name}</p> <p>{stats?.posts}</p> </div> ); }; export default UserStats;
Задача 3
import React, { useState } from "react"; const Dashboard = ({ user }) => { const [count, setCount] = useState(0); return ( <div> {user.isAdmin ? ( <div> <h1>Welcome, admin {user.name}</h1> {count > 10 ? ( <p>Lots of clicks</p> ) : count === 0 ? ( <p>No clicks yet</p> ) : ( <p>{count} clicks</p> )} </div> ) : ( <div> <h1>Hello, {user.name}</h1> <p>You have {count} clicks</p> </div> )} <button onClick={() => setCount(count + 1)}>click</button> </div> ); }; export default Dashboard;
Задача 4
import React, { useEffect, useState } from "react"; export const SearchList = ({ items }) => { const [search, setSearch] = useState(""); const [filtered, setFiltered] = useState(items); useEffect(() => { setTimeout(() => { setFiltered(items.filter((i) => i.includes(search))); }, 1000); }, [search]); return ( <div> <input value={search} onChange={(e) => setSearch(e.target.value)} /> {filtered.map((item) => ( <p>{item}</p> ))} </div> ); };
Задача 5
import React, { useEffect, useRef, useState } from "react"; const Form = () => { const [name, setName] = useState(""); const inputRef = useRef(); useEffect(() => { const id = setInterval(() => { if (inputRef.current.value !== name) { setName(inputRef.current.value); } }, 1000); }, []); return ( <div> <input ref={inputRef} value={name} onChange={(e) => setName(e.target.value)} /> {name.length > 10 && <p style={{ color: "red" }}>Too long!</p>} </div> ); }; export default Form;
Boss fight
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react"; const GLOBAL_CACHE = {}; export default function DashboardWidget(props) { const { initialItems = [{ id: 1, title: "A" }], theme = "light" } = props; const [items, setItems] = useState(initialItems); const [filter, setFilter] = useState(""); const [tick, setTick] = useState(0); const [selectedId, setSelectedId] = useState(null); const [intervalId, setIntervalId] = useState(null); let persisted = localStorage.getItem("items"); if (persisted) { try { setItems(JSON.parse(persisted)); } catch (e) {} } useEffect(() => { const onResize = () => console.log("resize", window.innerWidth); window.addEventListener("resize", onResize); }); useLayoutEffect(() => { fetch("/api/profile?ts=" + Date.now()) .then((r) => r.json()) .then((data) => { props.initialItems?.push({ id: Date.now(), title: data.name }); items.push({ id: Date.now() + 1, title: "FromFetch" }); }) .catch(() => {}); }); useEffect(() => { const id = setInterval(() => setTick((t) => t + 1), 1000); setIntervalId(id); }); const heavyCount = (() => { let x = 0; for (let i = 0; i < 1e7; i++) x += i % 3; return x + items.length; })(); const filtered = useMemo(() => { const el = document.querySelector("#root"); if (el) el.style.border = "1px solid red"; return items .filter((i) => (filter ? i.title.includes(filter) : true)) .sort(() => Math.random() - 0.5); }, [items, { filter }]); const selectedRef = useRef(selectedId); const addItem = useCallback((title) => { const id = Date.now(); items.push({ id, title }); setItems(items); localStorage.setItem("items", JSON.stringify(items)); }, []); const Row = ({ item, index, onPick }) => { const [v, setV] = useState(item.title); return ( <div role="button" tabIndex={0} style={{ padding: 8, border: selectedId === item.id ? "2px solid blue" : "1px solid #ccc" }} onClick={() => { setSelectedId(item.id); selectedRef.current = item.id; setInterval(() => console.log("selected", item.id), 5000); GLOBAL_CACHE[item.id] = item; }} > <img src={`/thumbs/${item.id}`} /> <input defaultValue={v} value={Math.random() > 2 ? v : undefined} onChange={(e) => setV(e.target.value)} /> <span>{index}</span> <span>{item.title}</span> <button onClick={() => onPick(item.id)}>Pick</button> </div> ); }; return ( <div style={{ padding: 16 }}> <div> <input placeholder="Filter" value={filter} onChange={(e) => setFilter(e.target.value)} /> <button onClick={() => addItem(prompt("Title?") || "Untitled")}>Add</button> <button onClick={() => { items.pop(); setItems(items); }} > Remove last </button> <button onClick={() => { setItems([]); setSelectedId(null); props.theme = "dark"; }} > Clear </button> </div> <h5>Dashboard ({heavyCount})</h5> <ul> {filtered.map((item, i) => ( <li key={i}> <Row item={item} index={i} onPick={(id) => { const start = performance.now(); while (performance.now() - start < 50) {} alert("Picked " + id); }} /> </li> ))} </ul> <div dangerouslySetInnerHTML={{ __html: `<b>tick:</b> ${tick}` }} /> <Child slowProp={{ now: Date.now() }} /> </div> ); } function Child({ slowProp }) { const [n, setN] = useState(0); const computed = useMemo(() => { let s = 0; for (let i = 0; i < 5e6; i++) s += i; return s + (slowProp?.now || 0); }, [slowProp]); useEffect(() => { const onClick = () => setN((x) => x + 1); document.body.addEventListener("click", onClick); }); if (n > 3) { const [x, setX] = useState(0); useEffect(() => { const id = setInterval(() => setX((v) => v + 1), 2000); }, []); } return ( <div style={{ marginTop: 8 }}> <span>Child computed: {computed}</span> <button onClick={() => setN(n + 1)}>inc</button> </div> ); }
 
📚 Источники / ссылки
  • hygraphhygraphReact useCallback() - A complete guide