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

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>
)


{
  "title": "mr",
  "first": "Steve",
  "last": "Buscemi"
}

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 dropdown
after 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} />
  </>
)