SPAで404をいい感じにやる

どうも、uzimaru です。

最近、SPAを書いていて404をいい感じに出したい〜〜〜ってなったのでそれについてまとめる

はじめに

前提の共有から

  • React
  • SPA
    • SSRとかはマジでやらないCSRのページ
  • API で取得するリソースには権限があったりなかったりする
  • 権限が無いとき・マジでリソースが無いときは NotFound の表示にする

という感じ。

また、fetch の段階で 404 等のエラーになっていたら Error を throw するようにします。

例えばエラーはこんな感じのやつがあるとする

tsx// 型の判別ができればいいので中身は空
class NotFoundError {}

解決方法

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

おそらく一番簡単なやつ

if 文でエラーの種類を見て 404 だったら NotFound の表示をする

tsxconst CatchErrorComponent = () => {
    const { data, error } = useSWR('/api/not-found', fetch)
    
    if (!data) {
        return "loading..."
    }

    if (error instanceof NotFoundError) {
        return 
    }

    return 
{JSON.stringify(data)}
}

一番シンプル、良さそう

ただ、複数のAPIを組み合わせて使うページの場合こうなる

tsxconst 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 
    }

    return 
{JSON.stringify(data)}
}

エラーの数だけ error instanceof NotFoundError を書かないといけない

まぁ書くだけ何だけど…

NotFound を識別する関数を作る

instanceof NotFoundError を書くのがダルいならそれをまとめてやってくれる関数を作る

tsxconst assertNotFound = (...errors: unknown[]): boolean => {
    for (const error of errors) {
        if (error instanceof NotFoundError) {
            return true
        }
    }

    return false
}
tsxconst 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 
    }

    return 
{JSON.stringify(data)}
}

記述量が減って嬉しい

若干微妙なのは if で見てるところと <NotFound> コンポーネントが各コンポーネントに出てくるところ

こんなケースになるとこれだとマズい

tsxconst ComponentA = () => {
    // 何か色々処理

    return 
{/* div地獄 */}
}

これだと他の要素に関係なしでNotFoundが出ちゃうので良くない

ErrorBoundary を使う

ここから本編

ErrorBoundary を使って NotFound の表示を制御する

tsxexport class NotFoundErrorBoundary extends Component {
  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 (
                <>
            
                    
                
      )
    }

    return this.props.children
  }
}

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

    return 
}

getDerivedStateFromError で降ってきたエラーが NotFoundError か確認している

注意するところは復帰導線を作った際に State の hasErrorfalse にしないといけない部分

また、さっき作った assertNotFound を修正する

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

NotFoundError だったらそのまま throw するようにする

これによりコンポーネントではこうなる

tsxconst 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 
{JSON.stringify(data)}
}

だいぶスッキリした印象

この方法のいいところはエラーの種類に応じて拡張可能なところ

NotFoundErrorBoundarygetDerivedStateFromError を別のエラーについて検査するようにしたら別のエラーのときの表示をすることができる。

tsxexport class ForbiddenErrorBoundary extends Component {
  ...

  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 
{JSON.stringify(data)}
}

まとめ

ErrorBoundary の利用が Suspense のときくらいだったが、今回の例でもいい感じに扱えた

assertNotFound みたいな関数がコンポーネント中に突然出てくるのが若干気になるのでもうちょっといい方法を模索していきたい