はじめに
PWA をホーム画面から起動すると、ブラウザの更新ボタンがない。アドレスバーもない。ユーザーにとって「データを最新にしたい」と思ったとき、手段がない状態になる。
IdeaSpool を PWA 対応する中で、まさにこの問題に直面した。アイデアリストを更新するには、一度アプリを閉じて再度開くしかない。ネイティブアプリなら当たり前にある Pull-to-Refresh(画面を引き下げてリロード)が、Web にはデフォルトで存在しない。
この記事では、IdeaSpool で Claude Code と一緒に Pull-to-Refresh を実装した過程を書く。閾値の設計、減衰係数によるネイティブ風の操作感、React コンポーネントとの統合まで、実際の判断プロセスを含めて紹介する。
Pull-to-Refresh が必要になった背景
IdeaSpool は、アイデアを投稿して AI が自動で分析・タグ付けしてくれるサービスだ。Phase 4 で PWA 対応を進めており、2026年1月21日の開発日記にはこう書いている。
PWA ではブラウザの更新ボタンがないので、リロード手段が必要
シンプルだが、これが本質だった。スタンドアロンモードで起動した PWA には、ブラウザ UI がない。ユーザーがデータを更新する手段を、アプリ側が用意しなければならない。
この日は他にも React.memo によるパフォーマンス改善や、iPad の Safe Area 対応など、PWA の細かい UX 課題をまとめて潰していた。開発日記の「やったこと」には、Phase 4.9 から 4.12 まで一気に並んでいる。
- Phase 4.9(Chrome パフォーマンス問題修正)を完了
- Phase 4.11(iPad PWA Safe Area 対応)を完了
- Phase 4.12(PWA リフレッシュ機能)を完了
Pull-to-Refresh(Phase 4.12)は、リフレッシュ手段として2つの方法を Claude Code で実装した。ロゴタップによるリフレッシュと、画面を引き下げる Pull-to-Refresh ジェスチャーだ。ロゴタップはヘッダーのロゴに onClick でリロードを仕込むだけなので簡単だった。しかし、Pull-to-Refresh はタッチイベントの制御、閾値の調整、アニメーションの設計と、想像以上に考えることが多かった。
実装アプローチの選択
Pull-to-Refresh を実装する方法はいくつかある。既存のライブラリを使うか、自作するか。ここが最初の判断ポイントだった。
Zeronova
Pull-to-Refresh を PWA に追加したい。npm にいくつかライブラリがあるけど、自作とどちらがいいだろう。IdeaSpool のリストビューに組み込む前提で。Claude Code既存ライブラリ(react-pull-to-refresh など)は汎用的ですが、PWA のスタンドアロンモードに最適化されていないものが多いです。特に iOS Safari ではtouchmoveイベントのpreventDefaultの挙動がブラウザ標準の引き下げリロードと競合することがあります。IdeaSpool の場合、リストのスクロール位置が先頭にあるときだけ Pull-to-Refresh を有効にする制御が必要なので、タッチイベントを自前で管理する方が確実です。Zeronova
確かに、スクロール途中で誤発動されると困る。自作で進めよう。タッチイベントの制御は任せる。
この判断でタッチイベントベースの自作実装に決めた。ライブラリに依存すると、PWA 特有の細かい制御が効かなくなるリスクがあると考えた。
閾値と減衰係数の設計
自作する以上、操作感の設計が重要になる。指で画面を引き下げたとき、どこまで引いたらリフレッシュを発動するか。引いている途中の「抵抗感」をどう表現するか。
開発日記の「悩んだこと」にも記録が残っている。
Pull-to-Refresh の感度 しきい値が低いと誤操作が増える → 80px + 抵抗感(減衰係数 0.5)で調整
閾値 80px は、ユーザーが意図的に引き下げたことを確認できる距離だ。40px 程度だと通常のスクロール操作で誤発動する。逆に 120px を超えると「引いても何も起きない」と感じてしまう。80px は、指を 1.5cm ほど動かす距離感で、意図的なジェスチャーとして自然に感じられる値だった。
減衰係数 0.5 は、引き下げ量に対して表示上の移動量を半分にするパラメータだ。指を 100px 引き下げても、UI 上は 50px しか動かない。この「重さ」がネイティブアプリの Pull-to-Refresh に近い感覚を生む。
Claude Code にこの方針で実装を依頼した。核となる考え方はシンプルだ。
// タッチ移動時の処理(概念)
const handleTouchMove = (e: TouchEvent) => {
const currentY = e.touches[0].clientY;
const diff = currentY - startY;
// スクロール位置が先頭でなければ無視
if (scrollTop > 0) return;
// 減衰係数を適用(引っ張る重さを表現)
const pullDistance = diff * 0.5;
if (pullDistance > 0) {
e.preventDefault();
setPullDistance(Math.min(pullDistance, 120));
}
};
減衰係数がないと、指の動きと UI の動きが 1:1 で対応する。これは一見自然に思えるが、実際に触ってみると「軽すぎる」と感じる。ネイティブアプリの Pull-to-Refresh は、引けば引くほど抵抗が増す設計になっている。0.5 という係数は、シンプルながらこの感覚を近似できた。
React コンポーネントとの統合
Pull-to-Refresh の仕組みそのものはタッチイベントで完結するが、React コンポーネントとの統合には注意が必要だった。
IdeaSpool ではアイデアカードのリスト表示があり、同じ Phase 4.9 で React.memo を適用してパフォーマンスを改善していた。
React.memo は Props が変わらないコンポーネントに効果的
Pull-to-Refresh のトリガーでデータを再取得すると、リスト全体が再レンダリングされる。せっかく React.memo で最適化したのに、リフレッシュのたびに全カードが再描画されては意味がない。
Claude Code に依頼して実装したのは、リフレッシュ状態とリストデータを分離する設計だ。Pull-to-Refresh の UI 状態(引き下げ距離、ローディング中かどうか)はリフレッシュ専用の state で管理し、リストデータの更新は既存のデータフェッチの仕組みに委譲する。
// リフレッシュ状態とリストデータを分離
const [isRefreshing, setIsRefreshing] = useState(false);
const [pullDistance, setPullDistance] = useState(0);
const handleRefresh = async () => {
setIsRefreshing(true);
await refetchIdeas(); // 既存のデータ取得関数
setIsRefreshing(false);
setPullDistance(0);
};
この設計により、Pull-to-Refresh の UI アニメーション(インジケーターの回転など)はリフレッシュコンポーネント内で閉じ、リストの再レンダリングはデータが実際に変わったカードだけに限定できた。React.memo との相性も良い。
もうひとつ気をつけたのは、touchmove イベントリスナーの登録と解除だ。useEffect でリスナーを登録し、クリーンアップ関数で確実に解除する。開発日記にも書いた通り、同じ日に setTimeout のメモリリーク修正もしていたので、イベントリスナーの後始末には特に注意を払った。
useEffect(() => {
const el = containerRef.current;
if (!el) return;
el.addEventListener('touchstart', handleTouchStart);
el.addEventListener('touchmove', handleTouchMove);
el.addEventListener('touchend', handleTouchEnd);
return () => {
el.removeEventListener('touchstart', handleTouchStart);
el.removeEventListener('touchmove', handleTouchMove);
el.removeEventListener('touchend', handleTouchEnd);
};
}, []);
視覚的なフィードバック
引き下げ中のインジケーター表示も、ネイティブ感を出すために工夫した。Pull-to-Refresh のインタラクションには3つの状態がある。
- 引き下げ中(閾値未満): 矢印アイコンが引き下げ量に応じて回転する。「もう少し引いてください」という視覚的な合図になる。
- 閾値到達: アイコンが完全に反転し、「リリースして更新」のテキストが表示される。ここで指を離すとリフレッシュが発動する。
- リフレッシュ中: スピナーが回転し、データ取得が完了すると滑らかに元の位置に戻る。
この段階的なフィードバックは、ユーザーに「あとどれくらい引けばいいか」を伝える役割がある。閾値の 80px に達する前に指を離せばキャンセルされるので、誤操作の心配もない。「引き始めたけどやめたい」というケースにも対応できる。
Claude Code にアニメーションの実装を依頼する際、CSS の transition を使うか、requestAnimationFrame を使うかで迷った。結論としては、引き下げ中は transition なし(指の動きにリアルタイムで追従)、リリース後のスナップバックは transition: transform 0.3s ease で滑らかに戻す、という使い分けに落ち着いた。引き下げ中に transition があると、指の動きとインジケーターの動きにラグが生じて不自然になる。一方、リリース後に transition がないと、インジケーターがぱっと消えて安っぽく見える。この使い分けが、ネイティブアプリに近い操作感を生む鍵だった。
学び
PWA の UX は「ないもの」を作る作業
開発日記にも書いた通り、PWA の UX は細かい部分で差がつく。
PWA の UX は細かい部分(リフレッシュ手段など)で差がつく
ブラウザには更新ボタン、アドレスバー、戻るボタンがある。PWA のスタンドアロンモードでは、それらがすべて消える。ネイティブアプリなら OS が提供してくれる機能も、Web では自前で実装しなければならない。Pull-to-Refresh はその代表例だ。
数値の根拠を持つことの重要性
80px という閾値と 0.5 という減衰係数は、試行錯誤の結果だ。最初から正解だったわけではない。しかし「なぜその値か」を説明できる数値を選ぶことで、後から調整が必要になったときにも方針がぶれにくい。「なんとなく良さそうだから」で決めた値は、問題が起きたときに「どう変えればいいか」が分からなくなる。80px は「誤操作防止と操作性のバランス」、0.5 は「ネイティブ風の抵抗感」という明確な根拠がある。もし将来「引きが重すぎる」というフィードバックがあれば、減衰係数を 0.6 や 0.7 に上げればいい。根拠があれば調整の方向が見える。
パフォーマンス最適化との両立
Pull-to-Refresh を入れたことで、リフレッシュ時にリスト全体が再レンダリングされる問題が起きる可能性があった。React.memo による最適化と Pull-to-Refresh の状態管理を分離する設計を同時に考えられたのは、同じ日にパフォーマンス改善と Pull-to-Refresh を並行して進めていたからだと思う。異なる課題を同時に扱うことで、設計の整合性が自然と取れることがある。
まとめ
PWA に Pull-to-Refresh を実装する際のポイントは3つだ。
- 閾値と減衰係数でネイティブ感を出す -- 80px + 0.5 の組み合わせが出発点として使いやすい
- スクロール位置の判定を忘れない -- リスト先頭でのみ発動させないと誤操作が頻発する
- React の状態管理と分離する -- リフレッシュ UI の state とデータの state を混ぜない
PWA はネイティブアプリの体験に近づけるほど、「Web では当たり前に存在していたもの」の不在に気づく。その一つひとつを丁寧に埋めていく作業が、PWA 開発の本質なのかもしれない。
関連記事
Zeronova(ゼロノバ)
Product Manager / AI-Native Builder
Web/IT業界19年以上・20以上のWebサービスを担当したPdM。東証プライム上場企業の子会社代表として事業経営を経験。現在はAIを駆使して企画から実装まで完結させる個人開発を実践中。