package.json から1行消せるはずでした。消えませんでした。
Expo UI は SDK 56 で安定版になり、これまでコミュニティ製パッケージに任せていたピッカーやスライダーが、SwiftUI と Jetpack Compose の実物に置き換わりました。あわせて8件の drop-in 置き換えが用意されています。公式の案内は「多くの移行は import 1行の入れ替えで済みます」です。
個人開発で運用している壁紙アプリは、画像を横送りで見せる画面を持っています。ページ送りとページジャンプの両方を扱う画面なので、置き換え表に react-native-pager-view が載っているのを見たとき、ここは素直に1本減らせると思いました。
減らなかった理由は、置き換えた側ではなく、それを別の場所から要求しているパッケージの側にありました。しかも、その要求は dependencies ではなく peerDependencies に書かれていたので、package.json を眺めているだけでは目に入りません。
置き換えの是非を判断する前に、まず「そもそも減るのか」を機械的に出す必要がありました。
置き換え表は、削除できる依存の表ではありません
SDK 56 で用意された対応は次の8件です。左の依存を外し、右を使うという対応関係そのものは公開されています。
| これまでのパッケージ | 置き換え先 |
| @react-native-community/datetimepicker | @expo/ui/community/datetime-picker |
| @react-native-community/slider | @expo/ui/community/slider |
| react-native-pager-view | @expo/ui/community/pager-view |
| @react-native-picker/picker | @expo/ui/community/picker |
| @react-native-segmented-control/segmented-control | @expo/ui/community/segmented-control |
| @react-native-masked-view/masked-view | @expo/ui/community/masked-view |
| @react-native-menu/menu | @expo/ui/community/menu |
| @gorhom/bottom-sheet | @expo/ui/community/bottom-sheet |
この表が答えているのは「置き換え先が存在するか」だけです。「置き換えたら依存が1本減るか」には答えていません。両者は別の問いで、後者はプロジェクトごとに答えが変わります。
置き換えの動機が「見た目をOSに寄せたい」なら前者だけで足ります。動機が「ネイティブ依存を減らして SDK 更新のたびに壊れる箇所を減らしたい」なら、後者を先に確かめないと作業の見積もりを間違えます。私の動機は後者でした。
peerDependencies は package.json の grep に出てきません
手作業での確認は、だいたいこうなります。
grep react-native-pager-view package.json
# "react-native-pager-view": "^9.0.2"
直接依存に1件だけ。使っている画面も1つだけ。ならば置き換えれば消せる、と読みたくなります。
実際には、react-native-tab-view がこれを要求しています。要求の書かれている場所が dependencies ではないところが厄介です。
// react-native-tab-view@4.3.2 の package.json(抜粋)
{
"dependencies": {
"use-latest-callback": "^0.2.4"
},
"peerDependencies": {
"react": ">= 18.2.0",
"react-native": "*",
"react-native-pager-view": ">= 6.0.0"
}
}
peer は「自分では入れないが、入っていることを前提にする」宣言です。React Navigation のマテリアルトップタブを使っていれば react-native-tab-view が入りますから、タブを1つでも持っているアプリでは react-native-pager-view は残ります。自分の画面を全部 Expo UI に置き換えても、npm の依存としては1本も減りません。
ここが最初の落とし穴でした。減るかどうかを決めているのは、置き換える側ではなく、それを peer で要求している別のパッケージです。
依存グラフから判定するスクリプト
peer を含めた逆引きは手作業に向きません。インストール済みのツリーを走査して、8件それぞれについて「誰が要求しているか」「消したとき道連れで消えるものはあるか」を出すスクリプトを書きました。
scripts/expo-ui-dropin-audit.mjs として保存し、プロジェクトのルートで実行します。
#!/usr/bin/env node
// Expo UI drop-in 置き換えで「依存が本当に1本減るか」を判定する
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
import { join } from 'node:path';
const REPLACEABLE = {
'@react-native-community/datetimepicker': '@expo/ui/community/datetime-picker',
'@react-native-community/slider': '@expo/ui/community/slider',
'react-native-pager-view': '@expo/ui/community/pager-view',
'@react-native-picker/picker': '@expo/ui/community/picker',
'@react-native-segmented-control/segmented-control': '@expo/ui/community/segmented-control',
'@react-native-masked-view/masked-view': '@expo/ui/community/masked-view',
'@react-native-menu/menu': '@expo/ui/community/menu',
'@gorhom/bottom-sheet': '@expo/ui/community/bottom-sheet',
};
const root = process.argv[2] ?? process.cwd();
const readJson = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
const rootPkg = readJson(join(root, 'package.json'));
if (!rootPkg) { console.error(`package.json が見つかりません: ${root}`); process.exit(1); }
const directDeps = { ...(rootPkg.dependencies ?? {}) };
// node_modules を1段掘って(スコープ配下も含めて)全パッケージの宣言を集める
function collectInstalled(nmDir) {
const out = new Map();
if (!existsSync(nmDir)) return out;
for (const entry of readdirSync(nmDir)) {
if (entry.startsWith('.')) continue;
const full = join(nmDir, entry);
if (!statSync(full).isDirectory()) continue;
if (entry.startsWith('@')) {
for (const sub of readdirSync(full)) {
const pkg = readJson(join(full, sub, 'package.json'));
if (pkg?.name) out.set(pkg.name, pkg);
}
} else {
const pkg = readJson(join(full, 'package.json'));
if (pkg?.name) out.set(pkg.name, pkg);
}
}
return out;
}
const installed = collectInstalled(join(root, 'node_modules'));
// 「誰がこのパッケージを必要としているか」を runtime / peer に分けて逆引きする
const requiredBy = new Map();
const peeredBy = new Map();
const push = (map, key, value) => map.set(key, [...(map.get(key) ?? []), value]);
for (const [name, pkg] of installed) {
for (const dep of Object.keys(pkg.dependencies ?? {})) push(requiredBy, dep, name);
for (const dep of Object.keys(pkg.peerDependencies ?? {})) {
const meta = pkg.peerDependenciesMeta?.[dep];
if (meta?.optional) continue; // optional peer は残留理由にならない
push(peeredBy, dep, name);
}
}
const rows = [];
for (const [target, replacement] of Object.entries(REPLACEABLE)) {
if (!(target in directDeps) && !installed.has(target)) continue;
const runtime = (requiredBy.get(target) ?? []).filter((n) => n !== target);
const peers = (peeredBy.get(target) ?? []).filter((n) => n !== target);
// 対象を消したとき、道連れで消える孤児(対象だけが必要としていたもの)
const orphans = Object.keys(installed.get(target)?.dependencies ?? {}).filter((d) => {
const others = (requiredBy.get(d) ?? []).filter((n) => n !== target);
return others.length === 0 && !(d in directDeps);
});
const blockers = [...new Set([...runtime, ...peers])];
rows.push({
target, replacement,
direct: target in directDeps,
blockers,
removes: blockers.length === 0 ? 1 + orphans.length : 0,
orphans,
});
}
if (rows.length === 0) { console.log('置き換え対象のパッケージは入っていません。'); process.exit(0); }
let net = 0;
for (const r of rows) {
const head = r.blockers.length === 0 ? '削除できます' : '残ります';
console.log(`\n■ ${r.target} → ${r.replacement}`);
console.log(` 直接依存: ${r.direct ? 'はい' : 'いいえ(誰かが連れてきています)'}`);
console.log(` 判定: ${head}`);
if (r.blockers.length) console.log(` 残留理由: ${r.blockers.join(', ')} が要求しています`);
if (r.orphans.length) console.log(` 道連れで消える: ${r.orphans.join(', ')}`);
console.log(` 減る package 数: ${r.removes}`);
net += r.removes;
}
console.log(`\n合計: 8件中 ${rows.length} 件が該当し、実際に減るのは ${net} package です。`);
判定の要点は3つです。
第一に、dependencies と peerDependencies の両方から逆引きします。片方だけでは今回の react-native-pager-view を見落とします。
第二に、peerDependenciesMeta の optional: true は残留理由から除きます。任意の peer は入っていなくても壊れないので、依存を残す根拠になりません。ここを区別しないと、判定が過剰に「残ります」に倒れます。
第三に、対象を消したとき孤児になるパッケージを数えます。減る本数は1本とは限りません。
走らせた結果と、1件だけ残ったもの
置き換え候補のうち手元にあった5件を入れたツリーで実行した結果です。React Navigation のタブを想定して react-native-tab-view も入れてあります。
■ @react-native-community/datetimepicker → @expo/ui/community/datetime-picker
直接依存: はい
判定: 削除できます
減る package 数: 1
■ react-native-pager-view → @expo/ui/community/pager-view
直接依存: はい
判定: 残ります
残留理由: react-native-tab-view が要求しています
減る package 数: 0
■ @react-native-picker/picker → @expo/ui/community/picker
直接依存: はい
判定: 削除できます
減る package 数: 1
■ @react-native-masked-view/masked-view → @expo/ui/community/masked-view
直接依存: はい
判定: 削除できます
減る package 数: 1
■ @gorhom/bottom-sheet → @expo/ui/community/bottom-sheet
判定: 削除できます
道連れで消える: @gorhom/portal
減る package 数: 2
合計: 8件中 5 件が該当し、実際に減るのは 5 package です。
5件のうち4件は外せて、react-native-pager-view だけが残りました。私が最初に「ここは減る」と考えた1本です。
そして @gorhom/bottom-sheet は、単独で2本減ります。@gorhom/portal を連れてきていて、それを他の誰も使っていないためです。減る本数が最も多いのは、置き換えの難易度が最も高いパッケージでした。楽な順に片付けると、効果の大きい方が後回しになります。
道連れで消えるものと、共有されていて消えないもの
このツリーには invariant も入っています。@gorhom/bottom-sheet と @react-native-community/datetimepicker の両方が要求しているためです。
スクリプトは invariant を道連れに数えませんでした。正しい判定です。@gorhom/bottom-sheet だけを外しても、datetimepicker が残っている限り invariant は残ります。
ただしこれは1件ずつ評価したときの答えです。両方を同時に外せば invariant も落ちます。今回のスクリプトは意図的にそこまで踏み込んでいません。組み合わせを総当たりすると出力が読めなくなり、「どれから手をつけるか」という本来の問いから遠ざかるからです。
複数を同時に外す前提で正確な本数を知りたい場合は、外す予定のパッケージを引数で受け取り、それらをまとめて requiredBy から除いてから孤児を数える形に変えます。判定ロジックの追加は数行で済みますが、私は分けたままにしています。1件ずつ入れて1件ずつ様子を見る方が、壊れたときの切り分けが速いためです。
Host の内側は、フレックスボックスではありません
依存が減るかどうかとは別に、もう1つ確かめる軸があります。
Expo UI のコンポーネントは Host の内側に置きます。そしてその内側のレイアウトは、SwiftUI と Compose のレイアウト原語で処理されます。Yoga のフレックスボックスではありません。これは公式に明記されている挙動です。
つまり「import を1行入れ替えるだけ」の作業であっても、その場所でレイアウトエンジンの境界が動きます。既存のツリーが flex: 1 と justifyContent を前提に組まれていれば、置き換えた瞬間に配置が変わることがあります。
この崩れが本番のビルドで初めて表面化すると、原因の切り分けに時間がかかります。置き換えた直後にその画面だけを開いて目視する、という手順を1つ挟むだけで回避できるので、私はそこを省かないようにしています。
この2軸で並べると、着手の順番が見えてきます。
| レイアウト境界が動かない | レイアウト境界が動く |
| 依存が減る | 最初に着手します | 効果は大きいので、1本のアプリで先に試します |
| 依存が減らない | 見た目を寄せたいときだけ着手します | 後回しにします |
@react-native-picker/picker や @react-native-masked-view/masked-view は、置き換え先が単体で完結しやすく、左上に入りやすい部類です。@gorhom/bottom-sheet は右上に入ります。中身に既存のレイアウトを丸ごと抱えているためです。
共通コンポーネントを複数のアプリで共有している側の順番
私は同系統のアプリを複数本、共通のコンポーネントを持たせて運用しています。この構造だと、置き換えの判断が1本のアプリでは閉じません。
しかも App Store と Google Play の両方に出している以上、レイアウトが動く変更を入れれば、確認すべき画面は単純に倍になります。
共有している側に手を入れると、全部に同時に効きます。速いのは利点ですが、レイアウト境界が動く変更を共有側で行うと、確認すべき画面が一気に増えます。
そこで順番をこう決めました。
- 全アプリでスクリプトを走らせ、「減る本数」の表を作ります。1本も減らないアプリがあれば、そのアプリは今回の対象から外します
- 減る本数が同じなら、公開頻度がいちばん低いアプリから着手します。差し戻しの余地が大きいためです
- 共有コンポーネントに触れる置き換えは、単一アプリ側で1件通してから持ち込みます
react-native-tab-view のような peer 元を先に外せないか、別途検討します。ここが外れると判定表が書き換わります
依存が1本も減らないと判定に出たアプリについては、今回のラウンドでは置き換えを見送ることを推奨します。作業量に対して得られるものが「見た目が少しOSに寄る」だけになるためです。
4番目は今回の調査で初めて意識した項目です。置き換え作業そのものではなく、置き換えの効果を決めている前提条件の方を先に動かせないか、という問いになります。今の私の答えはまだ出ていません。タブのナビゲーションを外す判断は、依存を1本減らすためだけに下せる種類のものではないからです。
なお Expo SDK そのものの更新と組み合わせるなら、prebuild で消えるネイティブ変更の洗い出しを先に済ませておく方が安全です。Expo SDK 57 に上げる前に、prebuild で消えるネイティブ変更を洗い出す に手順をまとめてあります。複数アプリを並行して動かす場合の段階の切り方は、Rork が出した RN プロジェクトを New Architecture へ段階移行した検証メモ の進め方がそのまま使えます。
最初に走らせる1本
置き換えを検討しているなら、コードに触れる前にスクリプトを保存して1回走らせてみてください。
node scripts/expo-ui-dropin-audit.mjs .
出力の最後の行が「実際に減るのは 0 package です」なら、依存を減らす目的での置き換えは今回は成立しません。見た目をOSに寄せたいという別の動機があるかどうかを、そこで一度切り分けられます。
私自身、この判定を先にやっていれば、pager-view の置き換えに時間を割く前に順番を組み替えられました。実行に1秒もかからない確認を後回しにしていたわけで、そこは素直に反省しています。同じところで止まる方の役に立てば嬉しいです。