🙄
Expo RouterアプリでReact Server Componentsを使う
- 依存関係のエラー回避のため、expoに合わせてyarnを使った方がいい
- デフォルトのテンプレートはclient componentでしか動かないAPIを多用しているのでwith-routerで最小のルータープロジェクトを作る
yarn create expo-app --example with-router
cd my-app
yarn add -D react-server-dom-webpack@beta # おまじない
-
"reactServerFunctions": true,
がページコンポーネントからサーバーコンポーネントを呼び出すのに必要なフラグ -
reactServerComponentRoutes
はページコンポーネントをデフォルトでサーバーコンポーネントにする。境界値を調べたいので最初はオフにしておく
app.json
{
"expo": {
"scheme": "acme",
"plugins": [
"expo-router"
],
"experiments": {
"reactServerFunctions": true,
"reactServerComponentRoutes": false
}
}
}
- トップページを作る
- このコンポーネントはクライアントコンポーネントになる
app/index.tsx
import React from "react";
import { View, Button, ActivityIndicator } from "react-native";
import { PokemonBox } from "../components/pokemon";
export default function Page() {
return (
<View style={styles.container}>
<View style={styles.main}>
<View style={{ padding: 8, borderWidth: 1 }}>
<React.Suspense fallback={<ActivityIndicator />}>
<PokemonBox columns={3} />
</React.Suspense>
</View>
</View>
</View>
);
}
- PokemonBox全体をサーバーコンポーネントで定義する
components/pokemon.tsx
"use server";
import React from "react";
import { Text, View, Image } from "react-native";
import { fetchRandomPokemon } from "../actions/pokemon";
type Pokemon = {
name: string;
sprites: {
front_default: string;
};
abilities: {
ability: {
name: string;
};
}[];
};
export function PokemonView({ pokemon }: { pokemon: Pokemon }) {
return (
<View style={{ padding: 8, borderWidth: 1 }}>
<Text style={{ fontWeight: "bold", fontSize: 24 }}>{pokemon.name}</Text>
<Image
source={{ uri: pokemon.sprites.front_default }}
style={{ width: 100, height: 100 }}
/>
{pokemon.abilities.map((ability) => (
<Text key={ability.ability.name}>- {ability.ability.name}</Text>
))}
</View>
);
}
export function PokemonBox({ columns }: { columns: number }) {
return (
<>
{Array.from({ length: columns }).map(async (_, index) => {
const pokemon = await fetchRandomPokemon();
return <PokemonView key={index} pokemon={pokemon} />;
})}
</>
);
}
-
fetchRandomPokemon
はServer Functionsになる。これ単体でクライアントから呼び出せる - API Routes(Web API)の代わりになる
actions/pokemon.tsx
"use server";
export async function fetchRandomPokemon() {
const randomId = Math.floor(Math.random() * 100) + 1;
const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${randomId}`);
const json = await res.json();
return json;
}
- 開発サーバー起動
- web, ios, androidとも動作した
yarn expo start
- DevToolsで通信を見るとクライアントから
/_flight/web/ACTION_file:...
というエントリーポイントに何度もリクエストしているのがわかる - 一度で済ませて!
- ここで満を持してreactServerComponentRoutesフラグをオン
"experiments": {
"reactServerFunctions": true,
"reactServerComponentRoutes": true
}
-
/_flight/web/index.txt
から一発で一発でとって来るようになった - DevToolsで通信を見るとクライアントからPokemon APIにアクセスが行かずに、サーバーを経由してRSCで結果を受け取っているのがわかる
- スタンドアロンなアプリにするにはexportする
- まだwebしかexportできない
- モバイルから使う時はおそらくbaseUrlを設定してバックエンドを指定する?
yarn expo export -p web
yarn expo serve dist # ローカルで確認
- expo serve内部でNode.jsのサーバーが起動している
- デプロイ用にDockerfileを書いた
ARG NODE_VERSION=20.15.1
FROM node:${NODE_VERSION}-slim as base
WORKDIR /app
ENV NODE_ENV="production"
ARG YARN_VERSION=3.6.4
RUN corepack enable && \
yarn set version ${YARN_VERSION}
FROM base as build
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3
COPY .yarnrc.yml package.json yarn.lock ./
RUN yarn install --immutable
RUN yarn expo export -p web
COPY . .
FROM base
COPY /app /app
EXPOSE 3000
CMD [ "yarn", "expo", "serve", "dist" ]
考察
- この機能はAPI Routesの通信プロトコルをRSCペイロードに拡張したようなアーキテクチャだった
- SSRだとサーバーは完全なHTMLを返しクライアントでJSコードをでハイドレートするがExpo RouterはRSCペイロードを単純に送ってくる。これはモバイルでReact Nativeコンポーネントを再生する必要があるからだと思われる。現状のWeb版も一旦コンテンツなしのSPAのシェルをロードして、ペーロードを受け取り画面に描画する。
- 不変なViewはクライアントコンポーネントにして、動的な部分だけサーバーで動作させる。キャッシュする。などの戦略が必要
- 現状はRSCペイロードを使ったServer-Driven UIが実現できるもの(動いてるだけですごい)といったものだった
Discussion