Custom Hooks
- shorthand for a series of built-in hook calls
- follow the rules
- ALWAYS test by implementing in a component
- when using effects ALWAYS test unmounting
- when returning functions ALWAYS use
useCallback
Build-Your-Own Formik
- still use formik, this just shows that it’s not ✨magic✨
- cf. basic formik
import React, {
ChangeEventHandler,
createContext,
FC,
InputHTMLAttributes,
SelectHTMLAttributes,
useCallback,
useContext,
useState,
} from 'react'
// #region context
export interface FormContextType {
state: Record<string, string>
setState: (newState: Record<string, string>) => void
}
export const FormContext = createContext<FormContextType>(null!)
export const DebugForm: FC = () => {
const form = useContext(FormContext)
return <pre>{JSON.stringify(form.state, null, ' ')}</pre>
}
// #endregion
//
// #region form
export interface FormProps {
initialValues: Record<string, string>
}
export const Form: FC<FormProps> = ({ children, initialValues }) => {
const [state, setState] = useState(initialValues)
const context: FormContextType = {
state,
setState: useCallback(
(partial) => setState((current) => ({ ...current, ...partial })),
[],
),
}
return (
<FormContext.Provider value={context}>
<form>{children}</form>
</FormContext.Provider>
)
}
// #endregion
//
// #region custom hook
export interface UseForm<Element extends { value: string }> {
value: string
onChange: ChangeEventHandler<Element>
}
export const useForm = <Element extends { value: string }>(
name: string,
): UseForm<Element> => {
const form = useContext(FormContext)
const value = form.state[name]
const onChange: ChangeEventHandler<Element> = useCallback(
(event) => {
form.setState({ [name]: event.target.value })
},
[form, name],
)
return { value, onChange }
}
// #endregion
//
// #region form items using custom hook
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
name: string
}
export const Input: FC<InputProps> = ({ name, ...props }) => {
return <input {...useForm(name)} {...props} />
}
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
name: string
}
export const Select: FC<SelectProps> = ({ name, children, ...props }) => {
return (
<select {...useForm(name)} {...props}>
{children}
</select>
)
}
// #endregion
//
export default (
<Form initialValues={{ title: 'mr', first: 'Steve', last: 'Buscemi' }}>
<label>
title
<Select name="title">
<option value="mr">Mr</option>
<option value="mrs">Mrs</option>
<option value="ms">Ms</option>
<option value="dr">Dr</option>
<option value="lord">Lord</option>
<option value="prof">Prof</option>
<option value="esq">Esq</option>
<option value="dame">Dame</option>
<option value="na">N/A</option>
</Select>
</label>
<br />
<label>
first name
<Input name="first" />
</label>
<br />
<label>
last name
<Input name="last" />
</label>
<DebugForm />
</Form>
)
On Click Out Event
- a custom hook can return props in an object that can be spread
- short, expressive & reusable
<div {...useClickOut(() => setOpen(false))}>…</div>
- can cause issues, use sparingly
import React, {
FC,
MutableRefObject,
Ref,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import classes from './example.module.css'
// #region custom hook
export interface UseClickOut<T extends HTMLElement> {
ref: Ref<T>
}
export const useClickOut = <T extends HTMLElement>(
eventHandler: (event: MouseEvent) => void,
): UseClickOut<T> => {
const elementRef = useRef() as MutableRefObject<T>
// as ref to avoid spamming `useEffect`
const handlerRef = useRef<typeof eventHandler>(eventHandler)
handlerRef.current = eventHandler
useEffect(() => {
const filteredHandler = (event: MouseEvent) => {
const clickTarget = event.target as Node
const isTargetInsideElement = elementRef.current.contains(clickTarget)
if (!isTargetInsideElement) handlerRef.current(event)
}
document.addEventListener('click', filteredHandler)
return () => {
document.removeEventListener('click', filteredHandler)
}
}, [])
return { ref: elementRef }
}
// #endregion
//
// #region using custom hook
interface DropdownProps {
title: string
}
export const Dropdown: FC<DropdownProps> = ({ children, title }) => {
const [open, setOpen] = useState(false)
const toggle = useCallback(() => setOpen((o) => !o), [])
return (
<div className={classes.container} {...useClickOut(() => setOpen(false))}>
<button type="button" className={classes.toggle} onClick={toggle}>
{title} {open ? '🔽' : '🔼'}
</button>
{open && <div className={classes.content}>{children}</div>}
</div>
)
}
// #endregion
//
export default (
<div>
before dropdown
<Dropdown title="toggle dropdown">
<p>try clicking outside</p>
<button type="button">in dropdown</button>
</Dropdown>
after dropdown
</div>
)
before dropdownafter dropdown
Keyboard Navigation
- keyboard navigation is a common requirement for accessible components
- using context and custom hooks makes it possible to reuse this logic
import React, {
createContext,
FC,
KeyboardEventHandler,
MutableRefObject,
Ref,
useCallback,
useContext,
useLayoutEffect,
useRef,
} from 'react'
// #region context
type RefsType = HTMLElement[]
export interface NavigationContextType {
refs: MutableRefObject<RefsType>
register: (el: HTMLElement) => void
}
export const NavigationContext = createContext<NavigationContextType>(null!)
export const NavigationContextProvider: FC = ({ children }) => {
const refs = useRef<RefsType>([])
const next = useRef<RefsType>([])
// collect focusable elements
const register = useCallback((el: HTMLElement) => {
next.current.push(el)
}, [])
// once all elements are collected, publish on context
useLayoutEffect(() => {
refs.current = next.current
next.current = []
})
return (
<NavigationContext.Provider value={{ refs, register }}>
{children}
</NavigationContext.Provider>
)
}
// #endregion
//
// #region custom hook
export interface UseNavigation<T extends HTMLElement> {
ref: Ref<T>
onKeyDown: KeyboardEventHandler<T>
}
export const useNavigation = <T extends HTMLElement>(): UseNavigation<T> => {
const { refs, register } = useContext(NavigationContext)
const ref = useRef<T>() as MutableRefObject<T>
// ON EVERY RENDER, register element as focusable
useLayoutEffect(() => {
register(ref.current)
})
const onKeyDown = useCallback<KeyboardEventHandler<T>>(
(event) => {
const { length } = refs.current
const currentIndex = refs.current.indexOf(event.target as HTMLElement)
const navigationMap = {
ArrowLeft: currentIndex - 1,
ArrowUp: currentIndex - 1,
ArrowRight: currentIndex + 1,
ArrowDown: currentIndex + 1,
Home: 0,
PageUp: 0,
End: length - 1,
PageDown: length - 1,
} as Record<string, number>
const nextIndexCandidate = navigationMap[event.key]
if (nextIndexCandidate == null) return
event.preventDefault()
const nextIndex = (length + nextIndexCandidate) % length
const el = refs.current[nextIndex]
el.focus()
},
[refs],
)
return { ref, onKeyDown }
}
// #endregion
Tabs With Keyboard Navigation
import React, { FC, ReactNode, useCallback, useState } from 'react'
import classes from './example.module.css'
import { NavigationContextProvider, useNavigation } from './navigation'
export interface Tab {
id: string
title: string
content: ReactNode
}
interface InternalTabButtonProps {
activeTabId: string
setActiveTabId: (id: string) => void
tab: Tab
}
const InternalTabButton: FC<InternalTabButtonProps> = ({
activeTabId,
setActiveTabId,
tab: { id, title },
}) => {
const isActive = activeTabId === id
return (
<button
type="button"
className={classes.button}
onClick={useCallback(() => setActiveTabId(id), [setActiveTabId, id])}
{...useNavigation()}
>
{isActive ? '> ' : ''}
{title}
</button>
)
}
export interface TabsProps {
defaultTabId?: string
tabs: Tab[]
}
export const Tabs: FC<TabsProps> = ({ defaultTabId, tabs }) => {
const [{ id: firstTabId }] = tabs
const [activeTabId, setActiveTabId] = useState(defaultTabId ?? firstTabId)
const tabButtons = tabs.map((tab) => (
<InternalTabButton
key={tab.id}
activeTabId={activeTabId}
setActiveTabId={setActiveTabId}
tab={tab}
/>
))
const [activeTab] = tabs.filter(({ id }) => id === activeTabId)
return (
<div>
<div>
<NavigationContextProvider>{tabButtons}</NavigationContextProvider>
</div>
<div>{activeTab.content}</div>
</div>
)
}
const tabs = [
{ id: 'hello', title: 'Hello', content: <p>hello</p> },
{ id: 'world', title: 'World', content: <p>world</p> },
{ id: 'tabs', title: 'Tabs', content: <p>tabs</p> },
{ id: 'example', title: 'Example', content: <p>example</p> },
]
export default (
<>
<label>
focus a tab, then use arrow keys to navigate between them
<br />
<input placeholder="focus this, press tab" />
</label>
<Tabs defaultTabId="tabs" tabs={tabs} />
</>
)
tabs
Accordion With Keyboard Navigation
import React, { FC, ReactNode, useCallback, useState } from 'react'
import classes from './example.module.css'
import { NavigationContextProvider, useNavigation } from './navigation'
export interface AccordionItem {
id: string
title: string
content: ReactNode
}
interface InternalAccordionItemProps {
activeItemIds: Set<string>
toggle: (id: string) => void
item: AccordionItem
}
const InternalAccordionItem: FC<InternalAccordionItemProps> = ({
activeItemIds,
toggle,
item: { id, title, content },
}) => {
const isActive = activeItemIds.has(id)
return (
<div>
<button
type="button"
className={classes.button}
onClick={useCallback(() => toggle(id), [toggle, id])}
{...useNavigation()}
>
{isActive ? '🔽' : '🔼'} {title}
</button>
{isActive && <div>{content}</div>}
</div>
)
}
export interface AccordionProps {
items: AccordionItem[]
}
export const Accordion: FC<AccordionProps> = ({ items }) => {
const [activeItemIds, setActiveItemIds] = useState(new Set<string>())
const toggle = useCallback((id: string) => {
setActiveItemIds((current) => {
const nextItemIds = new Set(current)
if (nextItemIds.has(id)) {
nextItemIds.delete(id)
} else {
nextItemIds.add(id)
}
return nextItemIds
})
}, [])
return (
<div>
<NavigationContextProvider>
{items.map((item) => (
<InternalAccordionItem
key={item.id}
activeItemIds={activeItemIds}
toggle={toggle}
item={item}
/>
))}
</NavigationContextProvider>
</div>
)
}
const items = [
{ id: 'hello', title: 'Hello', content: <p>hello</p> },
{ id: 'world', title: 'World', content: <p>world</p> },
{ id: 'accordion', title: 'Accordion', content: <p>accordion</p> },
{ id: 'example', title: 'Example', content: <p>example</p> },
]
export default (
<>
<label>
use arrow keys to navigate between items
<br />
<input placeholder="focus this, press tab" />
</label>
<Accordion items={items} />
</>
)