個人開発SPAで作る並び替えUI 3系統|React + @dnd-kit + localStorage設計

概要

今回はReact + TypeScriptで作っている個人開発の在庫管理SPAで、「並び替え」を3系統に分けて実装した話について紹介していきます。

結論から書くと、画面の性質に応じて比較関数ベースのテーブル列ソート / @dnd-kitによるカードD&D / @dnd-kitによるリストD&Dの3つを使い分けています。

「並び替えくらい全部D&Dでよくない?」と最初は思っていたんですが、実際に作り込んでみると「列ヘッダで瞬時に切り替えたい場面」と「自分の好みで配置を保存したい場面」では要件が全然違うことに気付かされました(- -;

特にlocalStorageに何を入れて、何を入れないかの判断は、ユーザー体験を直接左右してしまいます。

本記事では実装コードを抜粋しつつ、「なぜこの方式を選んだのか」を設計判断の背景込みで紹介していきます。

並び替えの実装で苦戦している方は是非見ていてください!

それではやっていきましょう(^^!

目次

画面別の並び替えについて(全体マップ)

自分のアプリには並び替えが必要な箇所が4種類あって、それぞれ下記のような実装方式にしています。

画面 方式 並び替え対象 永続化
入出庫一覧 比較関数ベース 8カラム(日付・商品名・数量など) なし(in-memory)
在庫サマリ カードD&D KPI/グラフ/アラートの8カード localStorage["site_de_zaiko_tweaks_v1"]
カテゴリ/販売チャネル/セットマスタ リストD&D 各マスタのリスト順 localStorage["site_de_zaiko_v1"](reducer経由)
商品マスタ なし(検索・フィルタのみ)

ポイントは「比較関数ベースとD&Dによる手動順序保存の2系統」を、用途に応じて使い分けていることです。

この区分は永続化先の選び方とも対応していて、後半で3層構造として整理します。

テーブル列ソート(入出庫一覧画面)

まずは一番馴染みの深い、テーブル列ソートから。

実画面はこんな感じで、列ヘッダをクリックするとそのキーで並びが切り替わるオーソドックスな実装です。

入出庫一覧画面:日付列で降順ソートした状態
React製の入出庫一覧画面で日付列を降順ソートした状態

状態の型と初期値

カラム名と状態の型は、計算済み行の型 RecordWithGross(入出庫レコードに「金額」と「粗利」を事前計算で足した型)のキーから派生させています。

SortKey = keyof RecordWithGross にしておけば、新カラムを足したときに型側で自動的にソート対象に乗ってくれるので、追加時の付け忘れが起こりません。

src/screens/ListScreen.tsx

1
2
3
4
5
6
7
type TypeFilter = "all" | "in" | "out";
type SortKey = keyof RecordWithGross;

interface SortState {
key: SortKey;
dir: "asc" | "desc";
}

初期値は「日付の降順」

一覧画面に来たら直近のレコードが上に出る形にしています。
まぁ一般的な実装ですね!

src/screens/ListScreen.tsx

1
const [sort, setSort] = useState<SortState>({ key: "date", dir: "desc" });

比較関数

中核は1つのArray.prototype.sortコールバックだけです。

src/screens/ListScreen.tsx

1
2
3
4
5
6
7
8
9
10
11
12
computed.sort((a, b) => {
const va = a[sort.key];
const vb = b[sort.key];
const av: string | number = va == null ? "" : (va as string | number);
const bv: string | number = vb == null ? "" : (vb as string | number);
if (typeof av === "number" && typeof bv === "number") {
return sort.dir === "asc" ? av - bv : bv - av;
}
return sort.dir === "asc"
? String(av).localeCompare(String(bv))
: String(bv).localeCompare(String(av));
});

設計は次のとおりです。

  • nullは空文字列に正規化
    • 入庫レコードは出庫理由 (reason) を持たず、出庫レコードでも販売チャネル (channel) が空のことがあるなど、行ごとに値の有無が変わるフィールドが多いので、比較直前で null"" に寄せて「未入力セルは常に最小値(昇順なら先頭・降順なら末尾)」というルールを1か所で担保している
  • 数値と文字列で分岐
    • localeCompareは日本語にも対応した順序を返してくれるので、商品名(漢字混じり)の並びはブラウザのICUに丸投げできる
  • タイブレーカーなし
    • ES2019以降のArray.sortは安定ソート仕様なので、同じ日付のレコードが並んだ場合は入力順(≒ id採番順)が保たれる

副次キーを足すのは複雑度の割に旨味が薄いと判断して避けました。

ヘッダクリックの2系統UI

PCでは列ヘッダクリックで切り替えます。

src/screens/ListScreen.tsx

1
2
3
4
5
6
7
8
const sortBy = (key: SortKey) =>
setSort((current) =>
current.key === key
? { key, dir: current.dir === "asc" ? "desc" : "asc" }
: { key, dir: "desc" },
);
const sortInd = (key: SortKey) =>
sort.key !== key ? "" : sort.dir === "asc" ? " ▲" : " ▼";

ここは少しこだわって、「同じキーをクリックすると方向反転、新しいキーは降順から始める」ようにしています。

新キー切り替え時に毎回昇順スタートだと、数値カラムでは値の小さい行(多くは0や空)から表示されてしまって体感が悪いんですよね(- -;

降順スタートにしておくと「金額が大きい順」「日付が新しい順」と直感に合います。

スマホ用は別途、<select>と方向切替ボタンを並べた専用バーを用意しました。

表組みの列ヘッダはタップ領域として小さすぎる上、横スクロール中に押しにくいので分けた方が無難です。

src/screens/ListScreen.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<div className="sort-bar" aria-label="並び替え">
<label className="sort-bar-label">並び替え</label>
<select
className="input sort-bar-key"
value={sort.key}
onChange={(e) => setSort({ key: e.target.value as SortKey, dir: sort.dir })}
aria-label="並び替えキー"
>
<option value="date">日付</option>
<option value="productName">商品名</option>
{/* …他のカラム */}
</select>
<button
type="button"
className="btn btn-sm sort-bar-dir"
onClick={() => setSort({ key: sort.key, dir: sort.dir === "asc" ? "desc" : "asc" })}
aria-label={sort.dir === "asc" ? "昇順を降順に切替" : "降順を昇順に切替"}
>
{sort.dir === "asc" ? "▲ 昇順" : "▼ 降順"}
</button>
</div>

PCヘッダ用とスマホバー用でstateは同一なので、同じ列をクリックして並べた状態のまま画面幅を変えても並びが維持されます。

カテゴリ列でソートしつつフィルタを併用した例だと、こんな感じで件数が絞られてもソートのキー・方向はそのまま維持されます。

カテゴリ列でのソートとカテゴリフィルタを併用した状態
入出庫一覧でカテゴリ列ソートとフィルタを併用した画面

なぜ永続化しないか

ソート状態はuseStateだけで持っていて、localStorageには書いていません。

「画面を開いたら毎回最新順」の方が、保存された状態に戸惑うリスクより小さい、と自分は思います。

実際にフィルタを絞り込んで該当ゼロになった場合は、「該当するレコードはないにゃ」と表示されます。

フィルタとソートの併用例:条件次第で0件になるケースのUI
入出庫一覧でフィルタを絞って該当ゼロになった画面

ここで保存しちゃってると、次回開いたときも「該当なし」のまま表示されちゃうんですよね(^^;
「データが消えた??」と一瞬でも思うのはあまり設計としてよろしくないので、こんな設計にしています。

@dnd-kitを共通基盤に選んだ理由

カードD&DとリストD&Dの両方で同じライブラリを使い回したかったので、選定はそこそこ慎重にやりました。

package.json

1
2
3
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2"

選定理由は下記のとおり。

  • react-beautiful-dndは事実上メンテ停止状態
  • グリッド配置(カード並び替え)と縦リスト配置の両方をサポート
  • KeyboardSensorがデフォルトで付いてくる(a11yが公式の優先項目)
  • タッチセンサと併用するMouseSensor / PointerSensorで誤発火の閾値(distance / delay)を細かく指定できる

特に最後の「閾値を細かく指定できる」が後で聞いてきます!

共通の並び替えヘルパー

D&Dで配列を並び替える処理は、reorderList という純粋関数1つに集約しています。やっていることは「配列の from 番目の要素を抜いて to 番目に差し込む」だけのシンプルな関数です。

src/hooks/useInventoryData.ts

1
2
3
4
5
6
7
8
9
export const reorderList = (list: string[], from: number, to: number): string[] => {
if (from === to) return list;
if (from < 0 || from >= list.length) return list;
if (to < 0 || to >= list.length) return list;
const next = list.slice();
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved!);
return next;
};

在庫データは useReducer で1本にまとめていて、reorderCategory / reorderChannel / reorderSet(カテゴリ・販売チャネル・セットマスタそれぞれの並び替え用アクション)が、上記の reorderList と、型を問わないジェネリック版 reorderArray<T> を用途に応じて呼び分ける構造になっています。

ヘルパーをこの粒度で切り出しておくと、UI層は「fromto のインデックスを dispatch するだけ」で済むので、画面ごとに slice / splice を書き散らかさずに済むのが嬉しいところです。

冒頭3行の範囲外チェックを早期returnで弾いているのは、@dnd-kit の over が稀に古い id を返すケースへの保険でもあります。

サマリ画面のカードD&D

サマリ画面の8カード(主要指標・カテゴリ別在庫構成・月別売上粗利の推移・月別入出庫・出庫理由・販売チャネル・仕入先・在庫アラート)は、IDの配列として並び順を持ちます。

src/features/tweaks/types.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export type SummaryCardId =
| "kpi"
| "category"
| "money"
| "movement"
| "reason"
| "channels"
| "suppliers"
| "alerts";

export interface Tweaks {
theme: ThemeKey;
dark: boolean;
mascot: boolean;
summaryOrder: SummaryCardId[];
}

Tweaks は「テーマ・ダークモード・マスコット表示・サマリ画面のカード順」という、業務データには含めたくないユーザー単位のUI嗜好を1つにまとめた型で、業務データ本体とは別の localStorage キーに格納しています

src/features/tweaks/defaults.ts

1
export const TWEAKS_STORAGE_KEY = "site_de_zaiko_tweaks_v1";

業務データ本体は site_de_zaiko_v1Tweakssite_de_zaiko_tweaks_v1 と、別キーで分けています。

「バックアップ・エクスポートの対象範囲」が変わる単位で分けてあるのがポイントで、エクスポートJSONにカードの並び順は含めないので、将来別端末でインポートしても自然な挙動になります。

保存値の正規化

ここが個人的に一番強調したいパートで、保存値はいつか必ず壊れる前提の設計にしています。

src/features/tweaks/defaults.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
export const normalizeSummaryOrder = (
saved: readonly unknown[] | undefined | null,
): SummaryCardId[] => {
const seen = new Set<SummaryCardId>();
const result: SummaryCardId[] = [];
if (Array.isArray(saved)) {
for (const id of saved) {
if (
typeof id === "string" &&
SUMMARY_CARD_IDS.has(id as SummaryCardId) &&
!seen.has(id as SummaryCardId)
) {
result.push(id as SummaryCardId);
seen.add(id as SummaryCardId);
}
}
}
DEFAULT_SUMMARY_ORDER.forEach((id, i) => {
if (!seen.has(id)) {
result.splice(i, 0, id);
seen.add(id);
}
});
return result;
};

この関数が処理しているケースは以下です。

  • 保存されてない(初回起動 / 別端末)
    • 空配列+デフォルト追加で初期順になる
  • 不正値が混ざっている(型変更・タイポ・手動編集)
    • Setで型チェックして除外
  • 重複している(バグや並列タブによる書き込み)
    • seenで重複除去
  • カードが新しく追加された(バージョンアップ)
    • 保存になかったIDを DEFAULT_SUMMARY_ORDER 内の本来の位置(インデックス i)に splice で差し込むので、ユーザーが既に並べ替えた既存カードの順序は崩さず、新カードだけが「あるべき場所の近く」に滑り込む

これによって、Tweaksの型を変えずに新カードを追加できるようになります。

ロードコード側は「保存値はあてにせず、必ずnormalizeを通す」ことだけ守ればよくなるので、保守時の事故防止にとても効きます。

ドラッグハンドラとセンサー設定

src/screens/SummaryScreen.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 4 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);

const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = orderedIds.indexOf(active.id as SummaryCardId);
const newIndex = orderedIds.indexOf(over.id as SummaryCardId);
if (oldIndex < 0 || newIndex < 0) return;
setSummaryOrder(arrayMove(orderedIds, oldIndex, newIndex));
};

センサー調整がポイントで、それぞれの数値には意味があります。

  • マウスは4px以上ドラッグしないと開始しない → クリックとの誤認防止
  • タッチは200ms押下し続けないと開始しない → スクロール操作との競合回避
  • キーボードはsortableKeyboardCoordinatesで矢印キーをグリッド座標に変換

ドラッグ確定で @dnd-kit/sortable 付属の arrayMove(配列の要素を oldIndex から newIndex へ動かしたコピーを返すユーティリティ)を回し、結果を setSummaryOrder に流すと、上位の Tweaks 管理フック側が localStorage への書き込みまで面倒を見てくれます。

UI層は「正規化済みのカード順配列」と「並び順を更新するセッター」しか触らないので、責務がきっちり分かれます。

「並び替えモード」のトグル

普段はドラッグ不可、明示的に「並び替え」ボタンを押したときだけ並べ替えできるようにしています。

src/screens/SummaryScreen.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const SortableCard = ({ id, sortMode, children }: SortableCardProps) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id, disabled: !sortMode });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : undefined,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...(sortMode ? listeners : {})}
data-card-id={id}
>
{children}
</div>
);
};

disabled: !sortModeと、リスナをsortModeのときだけスプレッドする二重ガードです。

これがないと、グラフカードを普通にスクロールしようとしてうっかり並び替えてしまう、という事故が起きます(実際に開発中にやらかしました(- -;)。

「閲覧時は安全、編集時は明示的に」という考え方は、データグリッドではよく見るパターン(Excelの編集モードなど)ですが、ダッシュボードUIでも有効です。

マスタ管理のリストD&D

カテゴリ・販売チャネル・セットマスタの3つは、構造的に同じ「文字列リスト」なので、共通のMasterListコンポーネントを使い回しています。

SortableRowパターン

src/screens/masters/MasterList.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const SortableRow = ({
id,
name,
count,
canDelete,
deleteHint,
className = "masters-row",
onStartEdit,
onDelete,
}: SortableRowProps) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<li ref={setNodeRef} style={style} className={className}>
<button
type="button"
className="masters-drag"
aria-label={`${name} を並べ替え`}
{...attributes}
{...listeners}
>
⋮⋮
</button>
{/* …編集・削除ボタンなど */}
</li>
);
};

サマリ画面のSortableCardとの大きな違いは、ドラッグハンドル(⋮⋮ ボタン)だけに{...listeners}を渡していることです。

行全体をドラッグ可能にすると、「編集ボタンを押そうとしただけなのに行が動く」が頻発します(^^;

ハンドル方式にすれば、編集・削除ボタンとドラッグの操作面積がきっちり分かれるので、誤操作が激減します。

aria-label${name} を並べ替えにしておくと、スクリーンリーダーがどの行のハンドルかを読み上げてくれます。

ネストしたサブカテゴリD&D

カテゴリは「親カテゴリ > サブカテゴリ」の2階層構造で、サブの並び替えは親カテゴリの中に限定したいケースがあります。

@dnd-kitのドロップ先idはグローバルに一意である必要があるので、id自体を親でスコープ化します。

src/screens/masters/MasterList.tsx

1
2
3
4
5
6
7
8
9
10
const handleDragEnd = (e: DragEndEvent) => {
const { active, over } = e;
if (!over || active.id === over.id) return;
const from = subs.indexOf(String(active.id).replace(`sub::${parent}::`, ""));
const to = subs.indexOf(String(over.id).replace(`sub::${parent}::`, ""));
if (from === -1 || to === -1) return;
onReorder(parent, from, to);
};

const sortableIds = subs.map((s) => `sub::${parent}::${s}`);

sub::${parent}::${name}というプレフィックスで「どの親のどのサブか」を一意に表現し、ドラッグ確定時にプレフィックスを剥がしてインデックスに変換します。

これで親をまたぐドロップは構造上発生しません。

DndContextを親カテゴリごとに分けて配置しているので、別の親のサブにドロップする線そのものが引かれなくなります。

reducer経由の状態更新

UI層は「どのリストの、どこから、どこへ」のインデックスだけを dispatch して、配列の入れ替えと localStorage への書き込みは reducer 側に任せています。サブカテゴリのように string[] ではない要素を並び替えるケース向けに、reorderList と同じロジックを型変数 T で持ったジェネリック版 reorderArray<T> も並んで定義されています。

src/hooks/useInventoryData.ts

1
2
3
4
5
6
7
8
9
export const reorderArray = <T>(list: T[], from: number, to: number): T[] => {
if (from === to) return list;
if (from < 0 || from >= list.length) return list;
if (to < 0 || to >= list.length) return list;
const next = list.slice();
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved!);
return next;
};

カテゴリ・販売チャネル・セット・サブカテゴリのいずれも、reorder* アクションが dispatch される → reducer が reorderList / reorderArray を呼ぶ → 次のレンダリングのタイミングで localStorage に同期される」という同じ流れに集約されているので、新しく「並び替えできるマスタ」を足すときも、アクション型を1つ追加して reducer に case を1つ生やすだけで終わります。

永続化アーキテクチャの3層整理

ここまで出てきた並び替え状態は、3層に分かれて格納されています。

並び替えの種類 状態の置き場 並び替え方式
揮発的な閲覧状態 useState(in-memory) 比較関数 入出庫一覧のカラムソート
ユーザーのUI嗜好 localStorage["site_de_zaiko_tweaks_v1"] カードD&D サマリ画面のカード順
ドメインデータの一部としての順序 localStorage["site_de_zaiko_v1"](reducer) リストD&D カテゴリ・チャネル・セット

主張としては「何を並べているか」で永続化先が決まるということ。

  • 抽出条件に基づく一覧の見方
    • (フィルタ+ソート)は、画面を開き直したらリセットされるべき。次に来たときの状態を覚えていてくれる嬉しさよりも、覚えていたせいで混乱する害の方が大きい
  • ユーザーの好み
    • (カード順・テーマ・ダークモード)は本人だけのもの。エクスポートJSONに混ぜると別端末でインポートしたときに勝手に上書きされて鬱陶しい
  • マスタの並び順
    • ドメインデータの属性そのもの。カテゴリ「果物 → 野菜 → 飲料」と並べたいかどうかは、商品レコードの追加順やCSVエクスポートの順序と密に関わるので、業務データと同じストアに入る

3系統を1つのコンポーネントで担うのは過剰抽象化だと判断しました。

今のように分けたまま、それぞれの場所に「何のための並びか」を型名やlocalStorageキー名で語らせる方が、後から読み返すときに楽です。

アクセシビリティとキーボード操作

D&D部分は@dnd-kitのKeyboardSensorが標準で有効になっています。

  • フォーカスをハンドルに当ててSpaceまたはEnterで掴む
  • 矢印キーで方向移動(縦リストは ↑/↓、グリッドは ↑↓←→)
  • 再度Space / Enterで確定、Escapeでキャンセル

これは追加実装ゼロで効くので、sensors配列に入れ忘れないことだけ気をつければよいです。

まとめ

いかがでしたでしょうか?

並び替えはアプリの主役機能ではないですが、毎日触られる小さな操作の積み重ねが「触り心地」を決めるので、ちょっと丁寧に作る価値があります。

今回のポイントをまとめると下記のような感じになります。

  • 比較関数ベースのソートとD&D並び替えは別物
    • 前者は「見方」、後者は「データ/嗜好の属性」として割り切る
  • 永続化の粒度を分ける
    • 揮発/嗜好/ドメインの3層。バックアップやエクスポートの境界も同じ粒度に揃える
  • 保存値はいつか必ず壊れる
    • normalizeSummaryOrderのようにロード時に正規化を挟む層を持っておくと、新要素の追加にも不正値の混入にも一つで対応できる

1ユーザー × localStorage前提なのでこの設計に収まっていますが、マルチユーザー化・サーバ同期が入った場合は、normalizeSummaryOrder相当の処理を「サーバ → クライアント」の境界にもう一段追加することになりそうです。

逆に言えばその層が必要になるまでは、localStorageを直接信じない関数1つだけが境界として機能している、というシンプルな構造になっています。

似たような「個人開発でlocalStorageに何を残すか迷う」場面に出会ったら、参考にしてもらえると嬉しいです(^^b

以上となります。
今回は上手に並び替えの実装できました!
それではお疲れさまでした!