33
RouteManager UI coding patterns: React Hooks
This is an non-exhaustive list of the coding patterns the WorkWave RouteManager's front-end team follows. The patterns are based on years of experience writing, debugging, and refactoring front-end applications with React and TypeScript but evolves constantly. Most of the possible improvements and the code smells are detected during the code reviews and the pair programming sessions.
(last update: 2021, September)
Using hooks like
useMount
, useDidUpdate
, useUnmount
leads to mistakes and prevent thinking the hooks way. More: they prevent ESLint from prompting you about some slight but huge errors with the dependencies array.// ❌ don't
useMount(() => console.log('Component mounted!'))
// ✅ do
useEffect(() => console.log('Effect triggered!'), [])
React Hooks were built with co-location in mind, avoid creating generic-purpose hooks located away from their consumers.
// ❌ don't
import { useMount } from '@/hooks/useMount'
function Dialog(props:Props) {
useMount(() => {
autoSubmit()
})
/* ... rest of the code... */
})
// ✅ do
import { useAutoSubmit } from './useAutoSubmit'
function Dialog(props:Props) {
useAutoSubmit()
/* ... rest of the code... */
})
Effects without a dependency array trigger at every render, usually something unintended
// ❌ don't
useEffect(() => /* ... rest of the code... */)
// ✅ do
useEffect(() => /* ... rest of the code... */, [])
Memoization is helpful to
the former doesn't happen in case the memoized value is a primitive.
// ❌ don't
const isViewer = useMemo(() => {
return role === 'viewer'
}, [role])
// ✅ do
const isViewer = role === 'viewer'
We memoize almost everything, please leave a comment when something isn't memoized on purpose.
// ❌ don't
function useActions() {
return {
foo: () => {},
bar: () => {},
}
})
// ✅ do
function useActions() {
// not memoized because always consumed spread
return {
foo: () => {},
bar: () => {},
}
})
When the return value of a hook isn't a "unit" but just a way to return multiple, unrelated, items, and the consumers always spread them, don't memoize them.
function MyComponent() {
const { foo, bar } = useActions()
return <>
<button onClick={foo} />
<button onClick={bar} />
</>
})
// ❌ don't
function useActions() {
return useMemo(() => {
return {
foo: () => {},
bar: () => {},
}
}, [])
})
// ✅ do
function useActions() {
// not memoized because always consumed spread
return {
foo: () => {},
bar: () => {},
}
})
Never creates callbacks on the fly. It's not about performances (usually) but about avoiding triggering sub-components effects.
// ❌ don't
function Footer() {
const foo = () => {
/* ... rest of the code... */
}
return <>
<AboutUsButton onClick={foo} />
<PrivacyButton onClick={() => {
/* ... rest of the code... */
}} />
</>
})
// ✅ do
function Footer() {
const foo = useCallback(() => {
/* ... rest of the code... */
}, [])
const bar = useCallback(() => {
/* ... rest of the code... */
}, [])
return <>
<AboutUsButton onClick={foo} />
<PrivacyButton onClick={bar} />
</>
})
Hooks makes code harder to read, don't use them if not necessary.
// ❌ don't
import { action } from './actions'
function Footer() {
const onClick = useCallback(() => action(), [])
return <Icon onClick={onClick} />
})
// ✅ do
import { action } from './actions'
const onClick = () => action()
function Footer() {
return <Icon onClick={onClick} />
})
Built-in hooks should be hidden behind custom hooks which name explain their meaning.
// ❌ don't
function Dialog(props:Props) {
const { submit, programmatic, close } = props
useEffect(() => {
if(programmatic) {
submit()
close()
}
}, [submit, programmatic, close])
return <Buttons onSubmit={submit} onClose={close} />
})
// ✅ do
function useAutoSubmit(programmatic: boolean, submit: () => void, close: () => void) {
useEffect(() => {
if(programmatic) {
submit()
close()
}
}, [submit, programmatic, close])
}
function Dialog(props:Props) {
const { programmatic, submit, close } = props
useAutoSubmit(programmatic, submit, close)
return <Buttons onSubmit={submit} onClose={close} />
})
Then, move
useAutoSubmit
to a dedicated file.// ❌ don't
function useAutoSubmit(programmatic: boolean, submit: () => void, close: () => void) {
useEffect(() => {
if(programmatic) {
submit()
close()
}
}, [submit, programmatic, close])
}
function Dialog(props:Props) {
const { programmatic, submit, close } = props
useAutoSubmit(programmatic, submit, close)
return <Buttons onSubmit={submit} onClose={close} />
})
// ✅ do
import { useAutoSubmit } from './hooks/useAutoSubmit'
function Dialog(props:Props) {
const { programmatic, submit, close } = props
useAutoSubmit(programmatic, submit, close)
return <Buttons onSubmit={submit} onClose={close} />
})
Non-primitive variables trigger the reader's attention when they are part of the dependency arrays. When this is made on purpose, please leave a comment.
// ❌ don't
const { coordinates } = props
useEffect(() => {
scrollbar.scrollTo(coordinates.x, coordinates.y)
}, [coordinates])
// ✅ do
const { coordinates } = props
// The effect must depend directly on coordinates' x and y, otherwise the scrollbar can't be
// scrolled twice at the same position. The So, the consumer must memoize the `coordinates`
// object, otherwise the scrollbar scrolls at every render.
useEffect(() => {
scrollbar.scrollTo(coordinates.x, coordinates.y)
}, [coordinates])
If components/hooks have a lot of related refs, store them in a single ref and update them through a single
useEffect
. More: refs-related useEffect
s can be dependencies-free.// ❌ don't
const bookTitleRef = useRef(bookTitle)
useEffect(() => void (bookTitleRef.current = bookTitle))
const bookAuthorRef = useRef(bar)
useEffect(() => void (bookAuthorRef.current = bookAuthor))
const bookPublisherRef = useRef(bookPublisher)
useEffect(() => void (bookPublisherRef.current = bookPublisher))
// ✅ do
const bookRef = useRef({ bookTitle, bookAuthor, bookPublisher })
useEffect(() => void (refs.current = { bookTitle, bookAuthor, bookPublisher }))
An hooks can be cleaned up before an asynchronous execution completes, resulting in unexpected behaviors and errors.
// ❌ don't
export function useFetchItems() {
const [items, setItems] = useState()
useEffect(() => {
const execute = async () => {
const response = await fetchItems()
setItems(response)
}
execute()
}, [])
return items
}
// ✅ do
export function useFetchItems() {
const [items, setItems] = useState()
useEffect(() => {
let effectCleaned = false
const execute = async () => {
const response = await fetchItems()
if (effectCleaned) return
setItems(response)
}
execute()
return () => {
effectCleaned = true
}
}, [])
return items
}
If multiple states are related to the same entity or you mostly set them all at once, prefer a single state.
// ❌ don't
export function useOrder(order: Order) {
const [name, setName] = useState('')
const [depot, setDepot] = useState('')
const [eligibility, setEligibility] = useState('any')
useEffect(() => {
setName(order.name)
setDepot(order.depot)
setEligibility(order.eligibility)
}, [order.name, order.depot, order.eligibility])
return {
name,
depot,
eligibility,
}
}
// ✅ do
export function useOrder(order: Order) {
const [localOrder, setLocalOrder] = useState({})
useEffect(() => {
setLocalOrder({
name: order.name,
depot: order.depot,
eligibility: order.eligibility,
})
}, [order.name, order.depot, order.eligibility])
return localOrder
}
Don't put everything in a ref to optimize re-renders. A few re-renders are totally acceptable if they don't occur frequently and if avoiding them compromises the readability.
// ❌ don't
export function useActions(
/* ... rest of the code... */
) {
const { validateOnBlur } = validations
const api = useRef({
allowUnlistedValues,
setInputValue,
setTimeValue,
validations,
timeFormat,
inputValue,
timeValue,
setOpen,
options,
})
useEffect(() => {
api.current.allowUnlistedValues = allowUnlistedValues
api.current.setInputValue = setInputValue
api.current.setTimeValue = setTimeValue
api.current.validations = validations
api.current.timeFormat = timeFormat
api.current.inputValue = inputValue
api.current.timeValue = timeValue
api.current.setOpen = setOpen
api.current.options = options
}, [
allowUnlistedValues,
setInputValue,
setTimeValue,
validations,
timeFormat,
inputValue,
timeValue,
setOpen,
options,
])
const onBlur = useCallback(() => {
const {
allowUnlistedValues,
setInputValue,
setTimeValue,
inputValue,
timeFormat,
timeValue,
options,
setOpen,
} = api.current
/* ... rest of the code... */
}, [validateOnBlur])
/* ... rest of the code... */
}
// ✅ do
export function useActions(
/* ... rest of the code... */
) {
const { validateOnBlur } = validations
const api = useRef({
inputValue,
timeValue,
})
useEffect(() => {
api.current.inputValue = inputValue
api.current.timeValue = timeValue
}, [
inputValue,
timeValue,
])
const onBlur = useCallback(() => {
/* ... rest of the code... */
}, [
allowUnlistedValues,
validateOnBlur,
setInputValue,
setTimeValue,
timeFormat,
options,
setOpen,
])
/* ... rest of the code... */
}
more, you can even get the
useEffect
shorter by using void
and removing the dependency array.// ✅ do
export function useActions(
/* ... rest of the code... */
) {
const { validateOnBlur } = validations
const api = useRef({ inputValue, timeValue })
useEffect(() => void (api.current = { inputValue, timeValue }))
const onBlur = useCallback(() => {
/* ... rest of the code... */
}, [
allowUnlistedValues,
validateOnBlur,
setInputValue,
setTimeValue,
timeFormat,
options,
setOpen,
])
/* ... rest of the code... */
}
We exploit
useReducer
in the following main cases:useState
hosting non-atomic data or have many (aka more than 2-3) small useState
for different values, we should opt for useReducer
useEffect
which purpose is to update an object-based state but the update logic is not trivial