Open6

Next App Router を考える

うじまるうじまる

Next.js での App Router について考える
具体的には、Server/Client でのコードの棲み分けについてとその設計周りについて

うじまるうじまる

Server/Client でコードを一緒にしたい

できることなら、同じところからインポートしたら実行環境でよしなに出し分けてほしい
しかし無理

そこで ↓ のような構成を考えてみる

/lib
|- hoge/
    |- client.ts
    |- index.ts
    |- server.ts

index.ts には、Server/Client どちらでも使うコードを書く。例えば、型定義とか
server.ts, client.ts はそれぞれの環境に合った実装を書く

import するときは

// in server
import {} from './lib/hoge/server'

// in client
'use client'
import {} from './lib/hoge/client'

みたいに書く。import の path に server/client がつくのでちょっとわかりやすい

うじまるうじまる

I/F を統一する

一緒にしたいモチベーションとしてインターフェースを同じにしたい。
完全に同じとは言わなくても、返り値の型とかは一緒にしたい

例えば、型安全に cookie を扱う関数について考えてみる

// cookie/index.ts

export type TypedCookie<T extends string> = Record<
  T,
  string | number | object | boolean | null
>

export interface CookiesController<T extends string, S extends TypedCookie<T>> {
  getItem: (key: T) => S[T] | null
  setItem: (key: T, value: S[T]) => void
  removeItem: (key: T) => void
}

export type Key = 'token'
export type Cookies = {
  token: string
}

Interface をあわせたいので Inerface を使う
Server/Client はそれに合った実装をする

使う側での理想は interface を受け取ってそれを使う(=実装に依存しない)という形だが、実行環境によって DI なりなんなりをしないといけないので多分ダルい

うじまるうじまる

API 周りのまとめたい

API 周りもまとめたい
Server では普通に Async/Await で Promise を剥がすが、Client では SWR を使いたい
普通に Fetch してそれを出すだけ、のコンポーネントだったら Client 側で API のハンドルは不要なのだけど、ユーザーの操作とかに依存することが多分多いはず

ディレクトリの構成は先程と同じような形。API の絡んだ操作周りを usecase という名前にするのが好きなのでそうする

usecase/
|- preferences/
    |- client.ts
    |- index.ts
    |- server.ts

書き分けとしても先程と同じ
index.ts では API からデータを取ってくる部分と key の定義を書く

import { agent } from '@/lib/bsky'
import { AppBskyActorDefs } from '@atproto/api'

// リクエストを一意に識別できるKey
export const key = () => {
  return 'preferences'
}

// ある関心についてデータを取ってくるための関数
// 今回は Bluesky の Pin をした feed の情報を取ってくる
export const getPreferences = async () => {
  const pref = await agent.app.bsky.actor.getPreferences()
  const pinnedPref = pref.data.preferences.flatMap((x) => {
    if (AppBskyActorDefs.isSavedFeedsPref(x)) {
      return x.pinned
    }

    return []
  })

  return agent.app.bsky.feed.getFeedGenerators({
    feeds: pinnedPref,
  })
}

で、Server/Client では ↑ のコードを使って各々の環境に合ったデータフェッチをする

// in server
import { auth } from '@/lib/bsky/server'
import { getPreferences } from '.'

export const usePreferences = async () => {
  // この関数は認証が必要なので認証を通す
  await auth()
  // fetcher の値をそのまま返す
  return getPreferences()
}
// in client
import useSWR from 'swr'
import { getPreferences, key } from '.'

export const usePreferences = () => {
  const { data, error } = useSWR(
    // さっき定義した key を使う
    key(),
    () => {
      // Client では認証が通っているときにしか使われないので認証のロジックは無し
      return getPreferences()
    },
    {
      suspense: true,
    },
  )

  return {
    data,
    error,
  }
}

あとはよしなにこいつらを使う

import clsx from 'clsx'
import { SWRConfig } from '@/lib/swr'
import { unstable_serialize } from 'swr'
import { usePreferences } from '@/usecase/preferences/server'
import * as UsePreferences from '@/usecase/preferences'
import { Feed } from './_components/Feed/server'

export default async function Home() {
  const preferences = await usePreferences()

  return (
    <SWRConfig
      value={{
        fallback: {
          [unstable_serialize(UsePreferences.key())]: preferences,
        },
      }}
    >
      <main className={clsx('flex', 'flex-row', 'h-full', 'space-x-2')}>
        {preferences.data.feeds.map((feed) => {
          return <Feed key={feed.uri} generator={feed} />
        })}
      </main>
    </SWRConfig>
  )
}

SWRConfig で fallback の値を設定する。
こうすることで、Client で初回 fetch が完了する前に Server で fetch した値を使ってくれる
Client ではあとは普通のReactを書けば OK

うじまるうじまる

server/client 問わず、use*** という命名はどうなんじゃというところはあるが、まぁ、はい
server action とかを使うようになったら、client の mutate に合わせて実装すれば良さそう(そうなったら Client はいらなくなる...?)

うじまるうじまる
usecase/
|- preferences/
    |- client.ts
    |- index.ts
    |- server.ts

最近は ↑ というディレクトリ構造があったら、index.ts, server.tsget***client.tsuse*** にしてる。
RSC で use*** を await したら lint に怒られたので get*** に変えた。
index.tsfetch*** とかのほうがいいのかな?とかも思ってる