Best Practices

obviously opinionated, apply as appropriate

Tools

Components

  • prefer FC
  • prefer primitive state/props over objects/arrays
import React, { FC } from 'react'

export const BadFlexBox: FC<{
  place?: { align?: string; justify?: string }
}> = () => <></>

export const badFlexBox = (
  <BadFlexBox place={{ align: 'center' }}>children</BadFlexBox>
)

export const GoodFlexBox: FC<{
  align?: string
  justify?: string
}> = () => <></>

export const goodFlexBox = <GoodFlexBox align="center">children</GoodFlexBox>

export const Select: FC<{ options: string[] }> = () => <></>

const constantOptions = ['hello', 'world']
export const goodSelect = <Select options={constantOptions} />
export const badSelect = <Select options={['hello', 'world']} />
import React, { FC, InputHTMLAttributes } from 'react'

export const ClosedInput: FC<{ type?: 'text' | 'tel' }> = ({
  type = 'text',
}) => <input type={type} />

export const typeText = <ClosedInput />
export const typeTel = <ClosedInput type="tel" />
// export const cannotAddEvents = <ClosedInput onKeyDown={() => {}} />

export const OpenInput: FC<InputHTMLAttributes<HTMLInputElement>> = ({
  type = 'text',
  ...props
}) => <input type={type} {...props} />

export const canAddEvents = <OpenInput onKeyDown={() => {}} />
  • parent-child communication & global state via context
import React, {
  createContext,
  CSSProperties,
  FC,
  ReactNode,
  useContext,
  useMemo,
} from 'react'

// #region global
export interface GlobalContextType {
  theme: 'dark' | 'light'
}
export const GlobalContext = createContext<GlobalContextType>({ theme: 'dark' })

export const WithGlobal: FC = () => {
  const { theme } = useContext(GlobalContext)
  const style = useMemo(
    (): CSSProperties => ({ color: theme === 'dark' ? '#f8f8f2' : '#282a36' }),
    [theme],
  )
  return <div style={style}>children</div>
}
// #endregion

//

// #region local
export const LocalContext = createContext<{ index: number }>(null!)

export const List: FC<{ items: ReactNode[] }> = ({ items }) => {
  return (
    <ul>
      {items.map((item, index) => (
        <LocalContext.Provider key={Math.random()} value={{ index }}>
          {item}
        </LocalContext.Provider>
      ))}
    </ul>
  )
}

export const NumberedItem: FC = ({ children }) => {
  const { index } = useContext(LocalContext)
  return (
    <li>
      ({index + 1}) {children}
    </li>
  )
}

export const UnnumberedItem: FC = ({ children }) => {
  return <li>{children}</li>
}

const items = [
  <UnnumberedItem>hello</UnnumberedItem>,
  <UnnumberedItem>world</UnnumberedItem>,
  <NumberedItem>hello</NumberedItem>,
  <NumberedItem>world</NumberedItem>,
]
export default <List items={items} />
// #endregion
  • hello
  • world
  • (3) hello
  • (4) world

Performance

import React, {
  ChangeEventHandler,
  FC,
  memo,
  useCallback,
  useMemo,
  useState,
} from 'react'

const words = [
  'Lorem',
  'ipsum',
  'dolor',
  'sit',
  'amet',
  'consectetur',
  'adipiscing',
  'elit',
]

export const Slower: FC = () => {
  const [filter, setFilter] = useState('')

  return (
    <>
      <input
        aria-label="filter"
        placeholder="filter"
        value={filter}
        onChange={(event) => setFilter(event.target.value)}
      />
      <ul>
        {words
          .filter((word) => word.toLowerCase().includes(filter.toLowerCase()))
          .map((word) => (
            <li key={word}>{word}</li>
          ))}
      </ul>
    </>
  )
}

export const ProbablyFaster: FC = memo(() => {
  const [filter, setFilter] = useState('')
  const onChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
    (event) => setFilter(event.target.value),
    [],
  )
  const filteredWords = useMemo(
    () =>
      words.filter((word) => word.toLowerCase().includes(filter.toLowerCase())),
    [filter],
  )

  return (
    <>
      <input
        aria-label="filter"
        placeholder="filter"
        value={filter}
        onChange={onChange}
      />
      <ul>
        {filteredWords.map((word) => (
          <li key={word}>{word}</li>
        ))}
      </ul>
    </>
  )
})

export default <ProbablyFaster />
  • Lorem
  • ipsum
  • dolor
  • sit
  • amet
  • consectetur
  • adipiscing
  • elit

Organization

  • 1 feature per directory
  • 1 component per file
  • helper & tightly coupled components / hooks can be in same directory
  • keep business logic out of components
import React, { FC, useEffect, useState } from 'react'

// #region tangled business logic
export const WithBusiness: FC = () => {
  const [result, setResult] = useState(null)
  useEffect(() => {
    fetch('my-business/api/resource.json', {
      headers: { that: 'a', component: 'should', not: 'worry', about: '!' },
    })
      .then((r) => r.json()) // untyped interface
      .then(setResult)
    // no error handling
  }, [])
  return <>display {result} here</>
}

/**
 * @example tests
 * ```
 * describe('WithBusiness', () => {
 *   beforeEach(() => {
 *     // no no
 *     jest.spyOn(window, 'fetch').mockRejectedValue(new Error())
 *   })
 * })
 * ```
 */

// #endregion

//

// #region separate business logic
// #region my-api.ts
export interface Resource {
  defined: 'interface'
}
class MyAPIImpl {
  getResource(): Promise<Resource> {
    throw new Error('not implemented')
  }
}
export const MyAPI = new MyAPIImpl()

/**
 * @example tests
 * ```
 * describe('MyApi', () => {
 *   beforeEach(() => {
 *     // the only place where this has to be done
 *     jest.spyOn(window, 'fetch').mockRejectedValue(new Error())
 *   })
 * })
 * ```
 */
// #endregion

// #region use-my-resource.ts
export const useMyAPIResource = (): [null | unknown, Resource | null] => {
  const [result, setResult] = useState<Resource | null>(null) // typed
  const [error, setError] = useState<null | unknown>(null)
  useEffect(() => {
    let shouldUpdate = true
    MyAPI.getResource()
      .then((r) => shouldUpdate && setResult(r))
      .catch((e) => shouldUpdate && setError(e)) // unified error handling
    return () => {
      shouldUpdate = false
    }
  }, [])

  return [error, result] // clear interface that forces error handling
}
// #endregion

// #region my-resource.tsx
export const WithoutBusiness: FC = () => {
  const [error, resource] = useMyAPIResource()

  if (error) return <>oh no</>

  if (resource) return <>here it is {resource.defined}</>

  return <>loading</>
}

// it is much easier to mock either hook or api (don't mock both)
/**
 * @example tests
 * ```
 * jest.mock('./use-my-api-resource')
 * const useMyAPIResourceMock = useMyAPIResource as jest.MockedFunction<
 *   typeof useMyAPIResource
 * >
 * describe('WithoutBusiness', () => {
 *   beforeEach(() => {
 *     useMyAPIResourceMock.mockReturnValue([null, { defined: 'interface' }])
 *   })
 * })
 * ```
 */
// #endregion
// #endregion

Forms