![**Show HN:**
*useEffect でのロジックにうんざり?クラスベースの React 状態管理を作りました*
数年前から React を使ってシングルページアプリを構築してきましたが、何度も「`useEffect` にロジックを書く」臭いが出てくるパターンがあります。データ取得やサブスクリプションの設定、ローカルストレージの同期などを行うたびに、次のようなエフェクトフックを書いてしまいます。
```js
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);
```
構文は簡潔ですが、大きなコンポーネントになると煩雑になりがちです。私は次のようなものを求めていました。
- **状態ロジックを集約**
- **コンポーネントは宣言的に保つ**
- **予測可能な API を提供**
そこで、古典的なアイデアに立ち戻りました:軽量でクラスベースの状態管理者です。
### 実装例
```js
class Store {
constructor(initial = {}) {
this.state = initial;
this.listeners = new Set();
}
get(key) {
return this.state[key];
}
set(updater, callback) {
const prev = { ...this.state };
if (typeof updater === 'function') {
this.state = updater(this.state);
} else {
this.state = { ...this.state, ...updater };
}
this.listeners.forEach(cb => cb(prev, this.state));
if (callback) callback();
}
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}
```
### ストアの作成
```js
const counter = new Store({ count: 0 });
```
### コンポーネントでの利用例
```jsx
function Counter() {
const [count, setCount] = React.useState(counter.get('count'));
React.useEffect(() => {
const unsub = counter.subscribe((_, next) => setCount(next.count));
return unsub;
}, []);
const increment = () => counter.set(state => ({ count: state.count + 1 }));
return (
<div>
<p>{count}</p>
<button onClick={increment}>+</button>
</div>
);
}
```
### なぜうまくいくのか
- **エフェクトのボイラープレートが不要** – 状態変更はストア側で処理され、エフェクトを介さない。
- **単一真実源** – すべてのコンポーネントが同じインスタンスから読み書きする。
- **予測可能な更新** – リスナーは前状態と次状態の両方を受け取る。
### 注意点
- React の組み込み最適化(例:自動バッチ処理)が失われることがあります。
- 手動でサブスクライブ/アンサブスクライブする必要があるため、クリーンアップを忘れるとメモリリークに繋がります。
- 型安全はデフォルトでは保証されないので、必要なら TypeScript の型定義を追加してください。
### TL;DR
散らばった `useEffect` ロジックを、状態を集中管理する小さなクラスベースストアへ置き換えました。これによりコンポーネントは軽量で宣言的に保たれ、状態変更のタイミングや伝播方法を完全に制御できます。シンプルな状態更新で複数のエフェクトを管理することに疲れているなら、このパターンを試してみてください。](/_next/image?url=%2Fscreenshots%2F2026-04-09%2F1775692575135.webp&w=3840&q=75)
2026/04/09 6:53
**Show HN:** *useEffect でのロジックにうんざり?クラスベースの React 状態管理を作りました* 数年前から React を使ってシングルページアプリを構築してきましたが、何度も「`useEffect` にロジックを書く」臭いが出てくるパターンがあります。データ取得やサブスクリプションの設定、ローカルストレージの同期などを行うたびに、次のようなエフェクトフックを書いてしまいます。 ```js useEffect(() => { const id = setInterval(() => setCount(c => c + 1), 1000); return () => clearInterval(id); }, []); ``` 構文は簡潔ですが、大きなコンポーネントになると煩雑になりがちです。私は次のようなものを求めていました。 - **状態ロジックを集約** - **コンポーネントは宣言的に保つ** - **予測可能な API を提供** そこで、古典的なアイデアに立ち戻りました:軽量でクラスベースの状態管理者です。 ### 実装例 ```js class Store { constructor(initial = {}) { this.state = initial; this.listeners = new Set(); } get(key) { return this.state[key]; } set(updater, callback) { const prev = { ...this.state }; if (typeof updater === 'function') { this.state = updater(this.state); } else { this.state = { ...this.state, ...updater }; } this.listeners.forEach(cb => cb(prev, this.state)); if (callback) callback(); } subscribe(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); } } ``` ### ストアの作成 ```js const counter = new Store({ count: 0 }); ``` ### コンポーネントでの利用例 ```jsx function Counter() { const [count, setCount] = React.useState(counter.get('count')); React.useEffect(() => { const unsub = counter.subscribe((_, next) => setCount(next.count)); return unsub; }, []); const increment = () => counter.set(state => ({ count: state.count + 1 })); return ( <div> <p>{count}</p> <button onClick={increment}>+</button> </div> ); } ``` ### なぜうまくいくのか - **エフェクトのボイラープレートが不要** – 状態変更はストア側で処理され、エフェクトを介さない。 - **単一真実源** – すべてのコンポーネントが同じインスタンスから読み書きする。 - **予測可能な更新** – リスナーは前状態と次状態の両方を受け取る。 ### 注意点 - React の組み込み最適化(例:自動バッチ処理)が失われることがあります。 - 手動でサブスクライブ/アンサブスクライブする必要があるため、クリーンアップを忘れるとメモリリークに繋がります。 - 型安全はデフォルトでは保証されないので、必要なら TypeScript の型定義を追加してください。 ### TL;DR 散らばった `useEffect` ロジックを、状態を集中管理する小さなクラスベースストアへ置き換えました。これによりコンポーネントは軽量で宣言的に保たれ、状態変更のタイミングや伝播方法を完全に制御できます。シンプルな状態更新で複数のエフェクトを管理することに疲れているなら、このパターンを試してみてください。
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
提供された要約は明確で簡潔であり、キーポイントリストの主要なポイントを完全に網羅しています。改善の必要はありません。
本文
TL;DR:
Snapstate を作ったのは、React コンポーネントからビジネスロジックを取り除き、純粋な TypeScript クラスへ移すためです。その結果、React なしでテストできるストア、描画のみを担当するコンポーネント、UI とアプリケーションロジックの境界がより明確になりました。
Snapstate を選んだ理由
React は UI のレンダリングに優れていますが、アプリ全体のロジックを宿すには最適ではありません。時間とともに多くのコードベースは次のような構造へと流れていきます。
でデータ取得useEffect- カスタムフック内にビジネスルールを書き込む
を使って分散させた派生値useMemo- イベントハンドラの中に隠れたミューテーション
アプリは動作しますが、境界が曖昧になります。テストしやすく再利用したいロジックが、レンダリングタイミング・フックルール・コンポーネントライフサイクルに結びつきます。
私はもっとクリーンな分離を望みました:React は描画専用、純粋な TypeScript クラスは状態とビジネスロジック専用。
避けたかったこと
function Dashboard() { const { user } = useAuth(); const [stats, setStats] = useState(null); const [notifs, setNotifs] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { if (!user) return; setLoading(true); setError(null); Promise.all([ fetch(`/api/users/${user.id}/stats`).then(r=>r.json()), fetch(`/api/users/${user.id}/notifications`).then(r=>r.json()) ]) .then(([s, n]) => { setStats(s); setNotifs(n); }) .catch(err => setError(err.message)) .finally(() => setLoading(false)); }, [user]); const unreadCount = useMemo( () => notifs.filter(n=>!n.read).length, [notifs] ); const markAsRead = (id: string) => setNotifs(prev => prev.map(n=> n.id===id ? {...n, read:true} : n)); if (loading) return <Skeleton />; if (error) return <p>Failed to load: {error}</p>; return ( <div> <h1>Dashboard ({unreadCount} unread)</h1> <StatsCards stats={stats} /> <NotificationList items={notifs} onRead={markAsRead} /> </div> ); }
このコンポーネントは、認証状態の確認・データ取得・ローディング/エラーステート・派生データ・ミューテーション・描画といった多くの責務を抱えており、テストや再利用が難しくなります。
ストアベースのアプローチ
認証ストア
interface AuthState { user: User | null; token: string; } class AuthStore extends SnapStore<AuthState, "login"> { constructor() { super({ user: null, token: "" }); } login(email: string, password: string) { return this.api.post({ key: "login", url: "/api/auth/login", body: { email, password }, onSuccess: res => { this.state.merge({ user: res.user, token: res.token }); } }); } logout() { this.state.reset(); } } export const authStore = new AuthStore();
ダッシュボードストア
interface DashboardState { userId: string; stats: { revenue: number; activeUsers: number; errorRate: number } | null; notifications: { id: string; message: string; read: boolean }[]; } class DashboardStore extends SnapStore<DashboardState, "load"> { private unreadCount_ = this.state.computed( ["notifications"], s => s.notifications.filter(n=>!n.read).length ); constructor() { super({ userId: "", stats: null, notifications: [] }); this.derive("userId", authStore, s => s.user?.id ?? ""); } load() { const uid = this.state.get("userId"); return this.api.all({ key: "load", requests: [ { url: `/api/users/${uid}/stats`, target: "stats" }, { url: `/api/users/${uid}/notifications`, target: "notifications" } ] }); } get unreadCount() { return this.unreadCount_.get(); } markAsRead(id: string) { this.state.patch("notifications", n=>n.id===id, { read:true }); } }
ビューは純粋に描画だけを担います。
function DashboardView({ stats, notifications, unreadCount, onMarkRead }: { stats: DashboardState["stats"]; notifications: DashboardState["notifications"]; unreadCount: number; onMarkRead: (id:string)=>void; }) { return ( <div> <h1>Dashboard ({unreadCount} unread)</h1> <StatsCards stats={stats} /> <NotificationList items={notifications} onRead={onMarkRead} /> </div> ); }
全体の接続
export const Dashboard = SnapStore.scoped(DashboardView, { factory: () => new DashboardStore(), props: store => ({ stats: store.getSnapshot().stats, notifications: store.getSnapshot().notifications, unreadCount: store.unreadCount, onMarkRead: id=>store.markAsRead(id) }), fetch: store => store.load(), loading: () => <Skeleton />, error: ({error}) => <p>Failed to load dashboard: {error}</p> });
ストアが振る舞いを担い、React は描画だけに専念します。この分離は可読性とテスト容易性を向上させます。
テストが簡単になる
describe("DashboardStore", () => { it("marks a notification as read", () => { const store = new DashboardStore(); store.state.set("notifications", [ { id:"n1", message:"Invoice paid", read:false }, { id:"n2", message:"New signup", read:true } ]); store.markAsRead("n1"); expect(store.unreadCount).toBe(0); expect(store.getSnapshot().notifications[0].read).toBe(true); }); });
レンダリングハーネスやプロバイダー、
act() は不要です。ビジネスロジックを直接実行できます。
今後の展開
この境界にコミットした結果、自然と次のような API が登場しました。
- Scoped stores:画面ごとのライフサイクル管理
- Form stores:Zod バリデーション付き
- URL 同期:URL を状態モデルの一部として扱う
これらは Snapstate の本来のアイデアから生まれた副産物であり、Snapstate を始めた理由ではありません。
Snapstate を試してみよう
Snapstate は GitHub でオープンソース化されており、npm 経由で利用できます。
npm install @thalesfp/snapstate
まだアルファ版ですが、コア API は本番環境でも安定しています。もしこの境界が役立ちそうなら、ドキュメントやサンプルアプリをチェックしてみてください。