Rork で作ったアプリを自分のスマホに入れて最初に開いたとき、リストが一行も並んでいない真っ白な画面に少し戸惑った——という経験はないでしょうか。説明文(プロンプト)から生成されるアプリは、「データが入っている状態」の画面は驚くほど綺麗に仕上がります。一方で、まだ何もない状態の画面は、AI が指示されない限りほとんど作り込んでくれません。
この「何もない画面」は専門的にはエンプティステート(空状態)と呼ばれます。地味ですが、ユーザーがアプリを初めて開いた瞬間に必ず通る画面でもあります。ここが空白のままだと、せっかくのアプリが「壊れているのかな」と誤解され、そのまま離脱につながります。私自身、個人開発でいくつものアプリを App Store と Google Play に出してきましたが、初日の離脱がいちばん減るのは、実はこの空っぽの画面を丁寧に整えたときでした。
空状態を「とりあえず文字を置く」レベルで終わらせると、もったいない画面になってしまいます。ここからは、初回・削除後・通信エラーの3つを1つの再利用コンポーネントで支え、リトライ・読み上げ対応・効果計測まで作り込んでいきます。Rork に任せきりにせず、生成されたコードのどこを直せば質が変わるのか、具体的な手の入れ方を順に見ていきます。
空状態は1種類ではない、という前提
エンプティステートを「データがないときの画面」とひとくくりにすると、設計を見誤ります。同じ空白でも、ユーザーが置かれている状況は3つに分かれていて、それぞれ伝えるべきメッセージが違うからです。
ひとつめは初回の空状態 です。アプリをインストールして初めて開いた、まだ何も登録していない状態。ここでユーザーが知りたいのは「自分は次に何をすればいいのか」です。
ふたつめはデータを削除しきった後の空状態 です。タスクを全部完了した、保存したアイテムを全部消した、という状況。これは初回とは気分がまったく違います。ユーザーは使い方を分かっているので、操作の説明は不要で、「やりきった」という達成感や、次の小さな一歩を添えるほうが合います。
みっつめは通信エラーや読み込み失敗による空状態 です。本来はデータがあるのに、取得に失敗して空に見えている状態。ここで「まだ何もありません」と表示してしまうと、ユーザーは自分のデータが消えたと勘違いします。この場合は「うまく読み込めませんでした。もう一度お試しください」という、再試行を促すメッセージが正解です。
この3つを分けて考えるだけで、空っぽの画面の印象は大きく変わります。そして実装上は、この3つに「読み込み中」を加えた4つの分岐を、画面ごとにバラバラに書くのではなく、1か所にまとめておくのが後々ずっと楽になります。
Rork に空状態を作ってもらうためのプロンプトの書き方
Rork は説明文に書かれていないものは基本的に作りません。逆に言えば、空状態を明示的に依頼すれば、ちゃんと画面を用意してくれます。生成のときに次のような一文を添えるのが効果的です。
リスト画面には、項目が0件のときに表示する初回向けの空状態を作ってください。
中央にやわらかいイラストかアイコン、「まだ◯◯がありません」という見出し、
一行の補足説明、そして「最初の◯◯を追加」というボタンを置いてください。
削除しきった後・通信エラー時はそれぞれ別の見出しと文言にし、
これらをまとめて1つの再利用コンポーネントとして実装してください。
ポイントは、空状態に「次の行動を促すボタン」を必ず含めるよう指示することと、3つの状態を1つのコンポーネントにまとめてほしい と明示することです。空状態は単なるお知らせではなく、ユーザーを最初の操作へ導く入り口だからです。すでにアプリを生成済みでも、Rork のチャットに「タスクが0件のときの画面が寂しいので、初回・削除後・エラーの3種を1コンポーネントにまとめて追加してください」と後から頼めば、その画面だけを追加してくれます。
4つの分岐を1つのコンポーネントに集約する
Rork が生成したコードを少し覗くと、空状態がどう制御されているかが分かります。多くの場合、画面ごとに if (isLoading) ... if (items.length === 0) ... という分岐がコピーされていて、修正するたびに全画面を直す羽目になります。ここを1つの <ListStateView> に集約すると、文言もデザインも一括で管理できるようになります。
type ListState = "loading" | "error" | "empty-first" | "empty-cleared" | "ready" ;
function resolveListState (
isLoading : boolean ,
hasError : boolean ,
itemCount : number ,
hasEverHadItems : boolean ,
) : ListState {
if (isLoading) return "loading" ;
if (hasError) return "error" ; // ← 必ず空判定より先に置く
if (itemCount === 0 ) {
return hasEverHadItems ? "empty-cleared" : "empty-first" ;
}
return "ready" ;
}
function ListStateView ({ state , onRetry , onAdd , children }) {
switch (state) {
case "loading" :
return < ListSkeleton rows = { 6 } />;
case "error" :
return (
< EmptyState
icon = "cloud-off"
title = "読み込めませんでした"
message = "通信状況をご確認のうえ、もう一度お試しください"
actionLabel = "再読み込み"
onAction = { onRetry }
/>
);
case "empty-first" :
return (
< EmptyState
icon = "sparkles"
title = "まだ項目がありません"
message = "右下のボタンから最初の項目を追加できます"
actionLabel = "項目を追加"
onAction = { onAdd }
/>
);
case "empty-cleared" :
return (
< EmptyState
icon = "check-circle"
title = "すべて完了しました"
message = "今日のぶんはここまで。お疲れさまでした"
actionLabel = "新しく追加"
onAction = { onAdd }
/>
);
default :
return children;
}
}
ここで大事なのは、hasError の判定を itemCount === 0 より先 に置くことです。順番が逆だと、通信に失敗して配列が空になったときにも「まだ項目がありません」という初回向けの文言が出てしまい、ユーザーがデータの消失を疑う原因になります。もう一点、hasEverHadItems(過去に一度でも項目があったか)を持っておくと、初回と削除後を機械的に出し分けられます。これはローカルに保存したフラグや、累計登録数のカウンタで十分です。生成されたコードがこの分岐になっているか、一度だけ確認しておくと安心です。
ローディングのスケルトンで「空状態のちらつき」を防ぐ
意外と見落とされるのが、読み込み中に一瞬だけ空状態が見えてしまう問題です。データ取得が走っている数百ミリ秒のあいだ items が空配列のままだと、初回向けの空状態がちらっと表示され、その直後にリストが出てくる。この瞬きは、アプリが不安定に見える地味な原因になります。
防ぎ方はシンプルで、読み込み中はスケルトン(リストの形をした薄いプレースホルダ)を出す ことです。スピナーをぐるぐる回すより、これから出てくるリストの輪郭を見せたほうが、体感の待ち時間は短くなります。
function ListSkeleton ({ rows = 6 }) {
return (
< View accessibilityLabel = "読み込み中" >
{ Array. from ({ length: rows }). map (( _ , i ) => (
< View key = { i } style = { styles.skelRow } >
< View style = { styles.skelAvatar } />
< View style = { styles.skelLine } />
</ View >
)) }
</ View >
);
}
Rork に頼むときは「読み込み中はスピナーではなく、リスト行の形をしたスケルトンを表示してください」と伝えると、この形に近いものを生成してくれます。isLoading が true のあいだは空状態の分岐に到達しないよう、先ほどの resolveListState で loading を最優先にしているのはこのためです。
通信エラーは「もう一度押すだけ」で終わらせない
エラーの空状態に「再読み込み」ボタンを置くのは第一歩ですが、ユーザーが連打しても無言で失敗が続くと、結局そのまま閉じられてしまいます。少しだけ賢いリトライにしておくと、体験が安定します。具体的には、失敗するたびに待ち時間を伸ばす指数バックオフを入れ、数回試してもだめなときだけエラー画面を見せる、という流れです。
async function fetchWithRetry < T >(
fetcher : () => Promise < T >,
{ retries = 3 , baseDelay = 500 } = {},
) : Promise < T > {
let lastError : unknown ;
for ( let attempt = 0 ; attempt <= retries; attempt ++ ) {
try {
return await fetcher ();
} catch (err) {
lastError = err;
if (attempt === retries) break ;
const delay = baseDelay * 2 ** attempt; // 500ms → 1s → 2s
await new Promise (( r ) => setTimeout (r, delay));
}
}
throw lastError;
}
加えて、直近に取得できたデータを端末内にキャッシュしておくと、オフラインでも「前回見た内容」を表示でき、空状態そのものを見せずに済みます。Rork には「データは AsyncStorage にキャッシュし、取得に失敗したらキャッシュを表示してから静かに再取得してください」と伝えると、この方針で組んでくれます。完全な空状態は、キャッシュも無く本当に何も出せないときの最後の砦として扱うのが、いちばん落ち着いた体験になります。
空状態を読み上げに対応させる
空状態は視覚的なデザインに目が行きがちですが、スクリーンリーダー(iOS の VoiceOver、Android の TalkBack)を使う方にとっては、ここが読み上げられないと「画面に何があるのか分からない」状態になります。せっかく書いた一言を、音声でもきちんと届けたいところです。
React Native では、空状態のまとまりに accessibilityRole と accessibilityLabel を付け、見出しと説明とボタンが順に読み上げられるようにします。
function EmptyState ({ icon , title , message , actionLabel , onAction }) {
return (
< View
accessibilityRole = "summary"
accessibilityLabel = { `${ title }。${ message }` }
style = { styles.empty }
>
< Icon name = { icon } size = { 48 } style = { styles.emptyIcon } />
< Text style = { styles.emptyTitle } > { title } </ Text >
< Text style = { styles.emptyMessage } > { message } </ Text >
{ actionLabel && (
< Pressable
accessibilityRole = "button"
accessibilityLabel = { actionLabel }
onPress = { onAction }
style = { styles.emptyButton }
>
< Text > { actionLabel } </ Text >
</ Pressable >
) }
</ View >
);
}
エラーから復帰したときなど、画面の状態が変わったことを能動的に知らせたい場合は、AccessibilityInfo.announceForAccessibility("項目を読み込みました") のように一言アナウンスを足すと親切です。読み上げの確認は、iOS なら設定からショートカットに VoiceOver を割り当て、実機で実際に画面をなぞってみるのがいちばん確実です。
3つの状態を実際に目で確かめる
設計したつもりでも、実機で見るまでは仕上がりが分かりません。3つの空状態は、それぞれ意図的に作り出して確認しておきます。初回の空状態は、アプリを入れ直すか、データをすべて削除すれば再現できます。削除後の空状態は、登録した項目を一つずつ消していけば確認できます。
少し手間がかかるのが通信エラーの空状態です。これは端末を機内モードにしてからデータ取得を伴う画面を開くと、手軽に再現できます。機内モードのまま「再読み込み」を押し、エラー画面のまま固まらないか、機内モードを解除して再度押したときにきちんとデータが戻るか——この往復を一度だけ試しておくと、本番運用でユーザーが圏外に入ったときの挙動を事前に確認できます。私自身、AdMob の広告を載せたアプリでは、通信が不安定なときの画面崩れがレビュー評価に直結すると感じてきたので、ここは必ず通すようにしています。
確認の観点を一つ加えるなら、スケルトン → 実データ、あるいはスケルトン → エラーへの切り替わりが滑らかかどうかも見ておきます。切り替えの瞬間にレイアウトが大きく跳ねると、せっかくのスケルトンが逆効果になってしまうためです。
どの空状態で離脱しているかを計測して文言を改善する
ここまで作り込んだら、最後は「どの空状態が効いているか」を数字で見られるようにします。空状態は表示されるだけでなく、そこからボタンが押されて次の操作につながって初めて意味を持つからです。表示と操作をそれぞれイベントとして記録しておくと、改善の手がかりになります。
useEffect (() => {
if (state. startsWith ( "empty" )) {
analytics. track ( "empty_state_shown" , { variant: state });
}
}, [state]);
// ボタンが押されたとき
function handleAction () {
analytics. track ( "empty_state_cta_tapped" , { variant: state });
onAdd ();
}
私の運用しているアプリでは、初回の空状態の見出しを「データがありません」から「最初の記録を追加してみましょう」に変えただけで、そこからの追加ボタンのタップ率が体感で目に見えて上がりました。表示回数に対してボタンが押された割合(タップ率)を1〜2週間ぶん見比べれば、どの文言が響いているかは自然と分かってきます。文言の比較は一度に1か所だけ変えるのがコツで、複数を同時に変えるとどれが効いたのか分からなくなります。地味な計測ですが、空状態は離脱の起きやすい場所だからこそ、ここを数字で磨く価値があります。
文言と余白で「丁寧さ」が伝わる
空状態は表示する情報が少ないぶん、一語一語が目立ちます。「データなし」とだけ書かれた画面と、「まだ記録がありません。今日のぶんから始めてみましょう」と書かれた画面では、受け取る印象がまるで違います。前者は機械的で、後者には書き手の体温が残ります。アプリ全体の語り口に合わせて、空状態の文言もそろえておきたいところです。
イラストやアイコンを中央に置き、上下にゆとりのある余白をとると、空白が「未完成」ではなく「意図された静けさ」に見えてきます。ここはコストをかけにくい部分ですが、Rork なら「空状態にやさしいイラストを足してください」と頼むだけで雰囲気が整います。画面設計全体の考え方はRork アプリの UX デザインパターン でも触れていますので、あわせて読んでいただけると流れがつかみやすいかもしれません。
まず初回の空状態から手を入れる
3つの状態を一度に完璧にしようとすると手が止まります。最初に整えるべきは、いちばん多くのユーザーが必ず通る初回の空状態です。ここに「次の一歩」を示すボタンが一つあるだけで、初日の離脱は目に見えて変わります。今お使いのアプリを開いて、データを空にした画面を一度確認してみてください。そこが寂しいままなら、まず初回の空状態を ListStateView にまとめるところから始め、慣れてきたらリトライ・読み上げ・計測へと一段ずつ広げていくのがおすすめです。Rork そのものの全体像を確認したい方はRork — AI でモバイルアプリを作るプラットフォームの概要 もどうぞ。
お読みいただきありがとうございました。小さな画面ほど、ユーザーへの気づかいが伝わる場所だと感じています。