設定画面は、アプリを1本だけ運用しているうちは何も問題になりません。トグルを足し、行を足し、画面に直接書いていけば動きます。困りはじめるのは、よく似たアプリが2本目、3本目と増えたときです。私自身、壁紙や癒し系の小さなアプリを個人開発で並行運用していますが、ある時期「同じ設定行を別々の画面に何度も書き写している」ことに気づきました。テーマ切り替え、通知のオン・オフ、キャッシュ削除——どのアプリにもある行を、アプリの数だけ別実装で抱えていたのです。
行が画面に直接書かれていると、変更は常に「アプリの本数 × 修正箇所」になります。通知設定の文言を一語変えるだけで、6か所を開いて回ることになる。これは作業量の問題というより、抜け漏れが生まれる構造の問題です。
ここで取り上げるのは、設定画面を「データ(スキーマ)」と「描画(レンダラ)」に分け、データを複数アプリで共有する設計です。Rork で生成した Expo アプリにそのまま載せられる形で書いていきます。
何を画面から剥がすのか
設定画面が抱えている責務を分解すると、おおよそ次の4つに分かれます。
| 責務 | 例 | どこに置くべきか |
| 項目の定義 | 「ダークモード」というトグルが存在する | スキーマ(共有) |
| 値の保存 | true/false を端末に永続化する | ストア(共有) |
| 見た目 | 行・スイッチ・区切り線の描画 | レンダラ(共有) |
| 固有の振る舞い | 「Pro 解除」行はこのアプリだけ | アプリ側の差分 |
最初の3つは、どのアプリでもほとんど同じです。違うのは4番目だけ。だからこそ、共有できる3つを徹底的に共有し、4番目を「差分」として小さく足せる構造にすると、運用がとても軽くなります。
スキーマを型で定義する
まず設定項目を表す型を決めます。ここが設計の背骨になります。画面ではなく、この型に責務を集めていきます。
// settings/schema.ts
export type SettingKind = "toggle" | "select" | "action" | "link";
export type SettingItem = {
key: string; // 永続化キー。アプリ間で衝突させない
kind: SettingKind;
titleKey: string; // i18n キー。生文字列は持たない
// toggle / select 用のデフォルト値
defaultValue?: boolean | string;
// select の選択肢
options?: { value: string; labelKey: string }[];
// action / link の実行内容
onPress?: () => void | Promise<void>;
// 表示条件。満たさなければ行ごと消える
visible?: (ctx: SettingsContext) => boolean;
};
export type SettingSection = {
titleKey: string;
items: SettingItem[];
};
export type SettingsContext = {
isPro: boolean;
platform: "ios" | "android";
};
ポイントは3つあります。titleKey を生文字列ではなく i18n キーにしておくこと。visible を関数にして「Pro 会員のときだけ出す」のような条件を宣言的に書けるようにすること。そして onPress 以外に振る舞いを持たせないこと。スキーマはあくまで「何があるか」を語る場所で、「どう描くか」は持たせません。
段階的に導入する手順
既存アプリにいきなり全面導入すると差分が大きくなりすぎます。私の場合は次の順で少しずつ移しました。
- テーマ・通知・キャッシュ削除という「どのアプリにもある3行」だけをスキーマ化し、共有ファイルに置きます。
- 1本のアプリでレンダラに差し替え、見た目が以前と一致するかをスクリーンショット比較で確認します。
- 問題がなければ残りのアプリへ共有ファイルを参照させ、固有行だけを各アプリで足します。
この3ステップなら、途中で止めても既存の画面が壊れません。一度に全部やろうとしないことが、移行を本番運用のまま進めるコツです。
共有スキーマとアプリ固有の差分
共有したいセクションは、別ファイルに定数として置きます。複数アプリのリポジトリから同じファイルを参照する形にすれば、ここが唯一の正本になります。
// settings/shared-sections.ts
import { SettingSection } from "./schema";
import { clearImageCache } from "../lib/cache";
export const appearanceSection: SettingSection = {
titleKey: "settings.appearance",
items: [
{
key: "theme",
kind: "select",
titleKey: "settings.theme",
defaultValue: "system",
options: [
{ value: "system", labelKey: "settings.theme.system" },
{ value: "light", labelKey: "settings.theme.light" },
{ value: "dark", labelKey: "settings.theme.dark" },
],
},
],
};
export const storageSection: SettingSection = {
titleKey: "settings.storage",
items: [
{
key: "clearCache",
kind: "action",
titleKey: "settings.clearCache",
onPress: clearImageCache,
},
],
};
アプリ固有の行は、共有セクションを壊さずに「足す」だけにします。たとえば Pro 解除の行は、そのアプリの画面ファイルでだけ組み立てます。onPress でペイウォールやオンボーディング画面へ遷移させる、といった固有の振る舞いもここに閉じ込めます。
// app/(tabs)/settings.tsx
import { appearanceSection, storageSection } from "../../settings/shared-sections";
import { SettingSection } from "../../settings/schema";
const proSection: SettingSection = {
titleKey: "settings.account",
items: [
{
key: "unlockPro",
kind: "action",
titleKey: "settings.unlockPro",
visible: (ctx) => !ctx.isPro, // 既に Pro なら行ごと消える
onPress: () => router.push("/paywall"),
},
],
};
export const sections: SettingSection[] = [
appearanceSection,
proSection, // ← このアプリだけの差分
storageSection,
];
並び順も配列の順番だけで決まります。アプリごとに「Pro 行を上に出したい/下に出したい」が違っても、配列の位置を変えるだけで済みます。
レンダラは一度書けば終わり
描画側は、スキーマを受け取って種類ごとに行を描くだけです。ここはアプリ間で完全に共通化できます。
// settings/SettingsRenderer.tsx
import { View } from "react-native";
import { useTranslation } from "react-i18next";
import { SettingSection, SettingsContext } from "./schema";
import { ToggleRow, SelectRow, ActionRow } from "./rows";
export function SettingsRenderer({
sections,
ctx,
}: {
sections: SettingSection[];
ctx: SettingsContext;
}) {
const { t } = useTranslation();
return (
<View>
{sections.map((section) => {
const items = section.items.filter((i) => i.visible?.(ctx) ?? true);
if (items.length === 0) return null; // 空セクションは見出しごと消す
return (
<View key={section.titleKey}>
<SectionHeader title={t(section.titleKey)} />
{items.map((item) => {
switch (item.kind) {
case "toggle": return <ToggleRow key={item.key} item={item} />;
case "select": return <SelectRow key={item.key} item={item} />;
default: return <ActionRow key={item.key} item={item} />;
}
})}
</View>
);
})}
</View>
);
}
visible を満たさない行を弾いてから、空になったセクションは見出しごと消す。この2行があるだけで、「Pro 会員には不要な行が見出しだけ残って気まずい」という、設定画面でよく起きる崩れを防げます。
値の保存はキーをスキーマに委ねる
永続化は、スキーマの key と defaultValue をそのまま使います。画面側は「どのキーをどこに保存するか」を一切知りません。
// settings/store.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect, useState } from "react";
import { SettingItem } from "./schema";
const NS = "settings.v1."; // 名前空間でアプリ内の他キーと混ざらない
export function useSetting(item: SettingItem) {
const [value, setValue] = useState(item.defaultValue);
useEffect(() => {
AsyncStorage.getItem(NS + item.key).then((raw) => {
if (raw != null) setValue(JSON.parse(raw));
});
}, [item.key]);
const update = async (next: boolean | string) => {
setValue(next);
await AsyncStorage.setItem(NS + item.key, JSON.stringify(next));
};
return [value, update] as const;
}
NS(名前空間)を付けておくのは、後で保存方式を MMKV や SQLite に差し替えたくなったときに、キーの衝突や移行が楽になるからです。スキーマがキーの正本を握っているので、移行スクリプトも「スキーマを舐めて旧キー→新キーに移す」だけで書けます。
つまずきやすい落とし穴
導入時に注意したい点が2つあります。1つ目は、key をアプリ間で安易にコピーしないことです。共有スキーマの theme はどのアプリでも同じ意味でなければなりません。あるアプリだけ theme に別の意味を持たせると、共有ファイルを直した瞬間に他アプリが壊れます。意味が違うなら別キーにする、という線引きを最初に決めておくと、後の事故を回避できます。
2つ目は、onPress の中に重いロジックを直接書かないことです。キャッシュ削除のような処理はスキーマから関数を呼ぶだけにとどめ、実体は lib/ 側に置きます。スキーマが肥大化すると、共有しているはずのファイルがアプリ固有の都合で汚れ、共有の意味が薄れてしまうためです。本番運用では、この境界が崩れたときにいちばん厄介なバグが出ます。
この設計が効くところ・効かないところ
万能ではありません。設定が10行に満たない単発アプリなら、素直に画面へ直書きしたほうが速いです。スキーマ駆動が報われるのは、似た設定を持つアプリが複数あるか、設定が育っていくと分かっているときです。私の場合は、通知文言の見直しを一度スキーマで直すと全アプリに反映される——その一点だけでも、導入して良かったと感じています。個人的には、3本以上の類似アプリを抱えた時点が導入の目安だと考えています。
逆に、行ごとにレイアウトがまったく違う凝った設定画面(スライダーwith プレビュー、地図埋め込み等)は、無理にスキーマへ押し込めないほうが健全です。この場合は kind: "custom" を1つ用意し、描画関数を差せる逃げ道だけ残しておくことをお勧めします。スキーマの一貫性を壊さずに例外を吸収できます。
まず手を付けるなら、テーマ・通知・キャッシュ削除という3行を共有セクションに切り出すところからで十分です。そこが正本になった瞬間から、設定画面はアプリの本数に比例して重くなる場所ではなくなります。