emoji

Topに戻る

SPAで404をいい感じにやる

emoji
2022/06/14 10:32
emoji
2022/06/14 02:59
Tech
どうも、uzimaru です。
最近、SPAを書いていて404をいい感じに出したい〜〜〜ってなったのでそれについてまとめる

はじめに

前提の共有から
  • React
  • SPA
    • SSRとかはマジでやらないCSRのページ
  • API で取得するリソースには権限があったりなかったりする
  • 権限が無いとき・マジでリソースが無いときは NotFound の表示にする
という感じ。
また、fetch の段階で 404 等のエラーになっていたら Error を throw するようにします。
例えばエラーはこんな感じのやつがあるとする
// 型の判別ができればいいので中身は空
class NotFoundError {}

解決方法

エラーを見て if で出し分け

おそらく一番簡単なやつ
if 文でエラーの種類を見て 404 だったら NotFound の表示をする
const CatchErrorComponent = () => {
  const { data, error } = useSWR('/api/not-found', fetch)
  
  if (!data) {
    return "loading..."
  }

  if (error instanceof NotFoundError) {
    return <NotFound />
  }

  return <div>{JSON.stringify(data)}</div>
}
例えばこんな感じ
一番シンプル、良さそう
ただ、複数のAPIを組み合わせて使うページの場合こうなる
const CatchErrorComponent = () => {
  const { data: data1, error: error1 } = useSWR('/api/not-found', fetch)
  const { data: data2, error: error2 } = useSWR('/api/not-found2', fetch)
  // ︙
  const { data: data100, error: error100 } = useSWR('/api/not-found100', fetch)
  
  if (!data1 || !data2 || ... || !data100) {
    return "loading..."
  }

  if (
    error1 instanceof NotFoundError ||
    error2 instanceof NotFoundError ||
    ...
    error100 instanceof NotFoundError
  ) {
    return <NotFound />
  }

  return <div>{JSON.stringify(data)}</div>
}
エラーがたくさんあるとちょっとだるい
エラーの数だけ error instanceof NotFoundError を書かないといけない
まぁ書くだけ何だけど…

NotFound を識別する関数を作る

instanceof NotFoundError を書くのがダルいならそれをまとめてやってくれる関数を作る
const assertNotFound = (...errors: unknown[]): boolean => {
  for (const error of errors) {
    if (error instanceof NotFoundError) {
      return true
    }
  }

  return false
}
const CatchErrorComponent = () => {
  const { data: data1, error: error1 } = useSWR('/api/not-found', fetch)
  const { data: data2, error: error2 } = useSWR('/api/not-found2', fetch)
  // ︙
  const { data: data100, error: error100 } = useSWR('/api/not-found100', fetch)
  
  if (!data1 || !data2 || ... || !data100) {
    return "loading..."
  }

  if (assertNotFound(
    error1,
    error2,
    ...,
    error100  
  )) {
    return <NotFound />
  }

  return <div>{JSON.stringify(data)}</div>
}
ちょっと良くなった
記述量が減って嬉しい
若干微妙なのは if で見てるところと <NotFound> コンポーネントが各コンポーネントに出てくるところ
こんなケースになるとこれだとマズい
const ComponentA = () => {
  // 何か色々処理

  return <div>
    <div>{/* div地獄 */}</div>
    <CatchErrorComponent />
  </div>
}
NotFoundコンポーネントを返す可能性のあるやつが子コンポーネントになっている
これだと他の要素に関係なしでNotFoundが出ちゃうので良くない

ErrorBoundary を使う

ここから本編
ErrorBoundary を使って NotFound の表示を制御する
export class NotFoundErrorBoundary extends Component<any, { hasError: boolean }> {
  constructor(props: any) {
    super(props)
    this.state = { hasError: false }
  }

  static getDerivedStateFromError(error: unknown) {
    if (error instanceof NotFoundError) {
      return { hasError: true }
    }
  }

  handleReload() {
    this.setState({ hasError: false })
  }

  render() {
    if (this.state.hasError) {
      return (
        <>
          <NotFound />
          <Reload onClick={this.handleReload} />
        </>
      )
    }

    return this.props.children
  }
}

const Reload = ({ onClick }: { onClick: () => void }) => {
  const navigate = useNavigate()
  const handleReload = useCallback(() => {
    navigate('/')
    onClick()
  }, [navigate])

  return <button onClick={handleReload}>←top</button>
}
getDerivedStateFromError で降ってきたエラーが NotFoundError か確認している
注意するところは復帰導線を作った際に State の hasErrorfalse にしないといけない部分
また、さっき作った assertNotFound を修正する
const assertNotFound = (...errors: unknown[]): void | never => {
  for (const error of errors) {
    if (error instanceof NotFoundError) {
      throw error
    }
  }
}
NotFoundError だったらそのまま throw するようにする
これによりコンポーネントではこうなる
const CatchErrorComponent = () => {
  const { data: data1, error: error1 } = useSWR('/api/not-found', fetch)
  const { data: data2, error: error2 } = useSWR('/api/not-found2', fetch)
  // ︙
  const { data: data100, error: error100 } = useSWR('/api/not-found100', fetch)
  
  if (!data1 || !data2 || ... || !data100) {
    return "loading..."
  }

  assertNotFound(
    error1,
    error2,
    ...,
    error100  
  )

  return <div>{JSON.stringify(data)}</div>
}
だいぶスッキリした印象
この方法のいいところはエラーの種類に応じて拡張可能なところ
NotFoundErrorBoundarygetDerivedStateFromError を別のエラーについて検査するようにしたら別のエラーのときの表示をすることができる。
export class ForbiddenErrorBoundary extends Component<any, { hasError: boolean }> {
  ...

  static getDerivedStateFromError(error: unknown) {
    if (error instanceof ForbiddenError) {
      return { hasError: true }
    }
  }

  ...
}

const assertForbidden = (...errors: unknown[]): void | never => {
  for (const error of errors) {
    if (error instanceof ForbiddenError) {
      throw error
    }
  }
}

const CatchErrorComponent = () => {
  const { data: data1, error: error1 } = useSWR('/api/not-found', fetch)
  const { data: data2, error: error2 } = useSWR('/api/not/permission', fetch)
  
  if (!data1 || !data2) {
    return "loading..."
  }

  assertForbidden(
    error1,
    error2
  )

  assertNotFound(
    error1,
    error2
  )

  return <div>{JSON.stringify(data)}</div>
}

まとめ

ErrorBoundary の利用が Suspense のときくらいだったが、今回の例でもいい感じに扱えた
assertNotFound みたいな関数がコンポーネント中に突然出てくるのが若干気になるのでもうちょっといい方法を模索していきたい
B!
emoji

Topに戻る

このサイトではアクセス解析のためにcookieを使用したGoogle Analyticsを使用しています。

© 2022

uzimaru