きっかけ
100 件の OSS を公開する、という目標を立てました。公開ペースが上がってきたところで気づいたのは、一覧ページをどこに持つか という問題。
- 会社の公式ホームページ (
sen.ltd) に手書き HTML で一覧を持つと、エントリを追加するたびに本体のデプロイが必要 - 一覧を静的 JSON にすると、今度はJSON の更新ごとに消費者側のビルドが発生
100 件に達することを考えると、この両方が摩擦の塊になります。答えは 「データと表示を完全に分離する」:
- データ:
portfolio/data/entries.json(single source of truth) - 表示: 実行時に
fetch('/portfolio/data.json')するフロントエンドアプリ
このアプリ (portfolio-app-react) が、そのフロントエンド。React + TypeScript + Vite で書いた landing page 本体です。
作ったもの
Portfolio App (React) — https://sen.ltd/portfolio/
ブラウズ機能フルセット:
- フィルタ: カテゴリ / スタック / ステージ
- 検索: 名前・ピッチ・タグどれでも
- ソート: 番号 / 新着 / 古い順 / 名前順
- URL クエリ同期: 絞り込み状態を共有リンク化
- 日英 UI
- ダーク UI (他の作品と統一感)
React 18 + TypeScript + Vite、gzip 49.00 kB。フィルタ・検索・ソートは pure 関数 (src/filter.ts) で実装、コンポーネント層から分離。
アーキテクチャの心臓部: ランタイム data.json fetch
普通の React アプリは、ビルド時に JSON を import してバンドルに含めます:
import data from '../data/entries.json' // ← バンドル時に固定
本アプリは違う。実行時に fetch します:
const res = await fetch('/portfolio/data.json', { cache: 'no-store' })
const data = await res.json()
違いが決定的なのは、エントリ追加したときのデプロイフローです:
portfolio/data/entries.json に 1 行追加
↓
./deploy-site.sh _data prod (JSON だけ S3 に upload, 2 秒で終わる)
↓
次回ユーザー訪問時に新データ反映(React アプリの再ビルド不要)
React アプリ側は一度 build したら触らない。100 件になっても 200 件になっても、アプリバンドルはそのまま。これが「データ駆動 landing」のキモ。
型定義は schema と一対一
data/entries.json の schema を TypeScript に写経:
export type Entry = {
slug: string
number: number
name: LocalizedText
pitch: LocalizedText
stage: StageId
source?: Source // 'public' | 'closed' (SaaS 案件)
category: string
tech: string[]
tags?: string[]
github: string | null
demo: string | null
image: string | null
articles: Article[]
testCount?: number
createdAt: string
}
export type PortfolioData = {
version: string
updatedAt: string
entries: Entry[]
categories: Category[]
stacks: Stack[]
stages: Stage[]
}
asserts 関数で実行時に検証してから使います:
function assertPortfolioData(raw: unknown): asserts raw is PortfolioData {
if (typeof raw !== 'object' || raw === null) throw new Error('not an object')
const obj = raw as Record<string, unknown>
if (!Array.isArray(obj.entries)) throw new Error('entries missing')
if (!Array.isArray(obj.categories)) throw new Error('categories missing')
// ...
}
ランタイム fetch の型安全性は、ランタイム検証で守るのが正解。as PortfolioData で黙らせると、壊れたデータが降ってきた時に UI が不可解にクラッシュします。TypeScript の asserts 構文は、チェック後にナローイングしてくれるので書き心地もいい。
フィルタ・検索・ソートは純粋関数
src/filter.ts にまとめた pure 関数:
export function filterAndSort(
entries: Entry[],
filter: FilterState,
lang: Lang
): Entry[] {
const q = filter.query.trim().toLowerCase()
const filtered = entries.filter((e) => {
if (filter.category !== 'all' && e.category !== filter.category) return false
if (filter.stack !== 'all' && !e.tech.includes(filter.stack)) return false
if (filter.stage !== 'all' && e.stage !== filter.stage) return false
if (q) {
const hay = (e.name[lang] + ' ' + e.pitch[lang] + ' ' + (e.tags ?? []).join(' ')).toLowerCase()
if (!hay.includes(q)) return false
}
return true
})
return sortEntries(filtered, filter.sort)
}
これをコンポーネントから分離しておくと、React の外でテストできる:
import { filterAndSort } from './filter'
// Vitest で pure 関数として直接テスト
14 テスト、すべて React なしで動く。コンポーネントはデータを受け取って描画するだけにする設計原則。
URL クエリ同期でシェアラブルに
絞り込み状態を URL に書き戻します:
function writeQuery(filter: FilterState, lang: Lang) {
const q = new URLSearchParams()
if (filter.query) q.set('q', filter.query)
if (filter.category !== 'all') q.set('category', filter.category)
if (filter.stack !== 'all') q.set('stack', filter.stack)
// ...
q.set('lang', lang)
window.history.replaceState(null, '', `${window.location.pathname}?${q.toString()}`)
}
history.replaceState を使って、履歴を汚さない。「React の案件だけ表示」した URL を友人にシェアできる。
逆に、URL からの読み込み:
function readQuery(): FilterState {
const q = new URLSearchParams(window.location.search)
return {
query: q.get('q') ?? '',
category: q.get('category') ?? 'all',
stack: q.get('stack') ?? 'all',
stage: q.get('stage') ?? 'all',
sort: (q.get('sort') as SortKey) ?? 'number',
}
}
これで ?category=dev-tool&sort=newest みたいなリンクが state を復元してくれます。
SaaS 案件のバッジ対応
OSS 目標の 100 カウントに混ぜたくないけど見せたい案件(ソース非公開の自作 SaaS)も受け入れるため、source: 'closed' フィールドをサポート:
{entry.source === 'closed' && (
<span className="source-badge">🔒 Closed source</span>
)}
そして header の meta line で OSS と SaaS をカウント分離:
{(() => {
const saas = data.entries.filter((e) => e.source === 'closed').length
const oss = data.entries.length - saas
return m.totalCount(oss, saas) // "OSS 30 件 · SaaS 1 件"
})()}
「100 件の OSS を目指す」目標と、「現実に持っている SaaS を載せる」要求を両立できる仕組み。データモデルに optional field を足すだけ で、既存の 30 エントリに影響を与えずに拡張できた。
フレームワーク比較シリーズの anchor
このアプリは 「同じ仕様・同じデータ・同じ CSS を複数のフレームワークで実装して比較する」 シリーズの 1 本目でもあります:
- 021 React (このエントリ) — gzip 49.00 kB
- 022 Vue 3 — gzip 28.76 kB (−41%)
- 023 Svelte 5 — gzip 18.92 kB (−61%)
- 024 Solid — gzip 8.33 kB (−83%)
- 025 Nuxt 3 — gzip 52.01 kB (+7%) ← 唯一 React より大きい
- 026 SvelteKit — gzip 32.50 kB (−33%)
- 027 Qwik — first-paint gzip 28.60 kB (−41%)
- 028 Astro islands — gzip 3.17 kB (−94%, 現時点で最小)
- 029 Lit 3 — gzip 9.70 kB (−80%)
- 030 Preact — gzip 8.75 kB (−82%)
共通コード (types.ts, filter.ts, data.ts, style.css, tests) は byte-identical、コンポーネント層だけが変わる設計。これにより「同じ仕事を各フレームワークがどれだけのバイトでこなすか」を純粋に比較できます。詳細は各実装の記事で。
テスト
Vitest で 14 ケース。filter.ts の pure 関数に対するテストが中心:
test('filters by category', () => {
const r = filterAndSort(entries, { ...defaults, category: 'dev-tool' }, 'ja')
assert.ok(r.every((e) => e.category === 'dev-tool'))
})
test('searches by pitch', () => {
const r = filterAndSort(entries, { ...defaults, query: 'cron' }, 'ja')
assert.ok(r.some((e) => e.slug === 'cron-tz-viewer'))
})
test('sorts by newest', () => {
const r = filterAndSort(entries, { ...defaults, sort: 'newest' }, 'ja')
for (let i = 0; i + 1 < r.length; i++) {
assert.ok(r[i].createdAt >= r[i + 1].createdAt)
}
})
全テストが React レイヤー抜きで走るので、実行は秒単位。UI テストと分離する のは長期メンテで効いてくる設計。
おわりに
SEN 合同会社の ポートフォリオシリーズ 100+ の 21 件目です。landing page 本体なので、シリーズ全体の基盤エントリ。
- 📦 レポジトリ: https://github.com/sen-ltd/portfolio-app-react
- 🌐 ライブデモ: https://sen.ltd/portfolio/
- 🏢 会社: https://sen.ltd/
次回(022〜)で、同じ仕様を Vue・Svelte・Solid 等に実装した記事が続きます。フレームワークのバイトサイズ比較に興味がある方はシリーズで。
