第6章 Hooks
関数コンポーネントでの状態管理
前章では、レンダリング処理を小さな単位に分割し非同期で実行する「Fiber」アーキテクチャを実装しました。この章では、関数コンポーネントで状態管理や副作用を扱うための「Hooks」を実装していきます。
Hooksとは何か
Hooksは、React 16.8で導入された機能で、関数コンポーネントでも状態(state)やライフサイクルなどの機能を使えるようにするものです。これにより、クラスコンポーネントを使わずに、より簡潔で再利用しやすいコードを書くことができます。
Hooksの基本ルール
Hooksを使う際には、以下の2つのルールを守る必要があります:
- トップレベルでのみ呼び出す: 条件分岐やループ、ネストした関数の中でHooksを呼び出してはいけません
- Reactの関数内でのみ呼び出す: 通常のJavaScript関数ではなく、Reactの関数コンポーネント内でのみHooksを呼び出します
これらのルールは、Hooksの内部実装が「呼び出し順序」に依存しているためです。
Hooksの内部構造
Hooksの実装を理解するために、まずその内部構造を見ていきましょう:
各Hookは、以下の情報を保持しています:
- state: フックの状態値
- next: 次のフックへのポインタ
これにより、複数のHooksが連結リストとして管理されます。
Hooksの実装
1. 型定義
まず、Hooksに必要な型を定義します:
typescript
// hooks/types.ts
// フックの型
export interface Hook {
state: any;
queue: UpdateQueue | null;
next: Hook | null;
}
// 更新キューの型
export interface UpdateQueue {
pending: Update | null;
}
// 更新の型
export interface Update {
action: any;
next: Update | null;
}
// ディスパッチ関数の型
export type Dispatch<A> = (action: A) => void;
// エフェクトの型
export interface Effect {
tag: EffectTag;
create: () => void | (() => void);
destroy: (() => void) | null;
deps: any[] | null;
next: Effect | null;
}
// エフェクトタグの型
export enum EffectTag {
NoEffect = 0,
LayoutEffect = 1,
PassiveEffect = 2
}2. Hooksコアの実装
次に、Hooksの核となる部分を実装します:
typescript
// hooks/core.ts
import { Fiber } from '../fiber/types';
import { Hook, UpdateQueue, Update } from './types';
// 現在の関数コンポーネントのFiber
let currentlyRenderingFiber: Fiber | null = null;
// 現在処理中のフック
let workInProgressHook: Hook | null = null;
// フックの初期化
export const prepareToUseHooks = (fiber: Fiber): void => {
currentlyRenderingFiber = fiber;
workInProgressHook = null;
};
// フックの後処理
export const finishHooks = (): void => {
currentlyRenderingFiber = null;
};
// 次のフックを取得
export const nextHook = (): Hook | null => {
if (!currentlyRenderingFiber) {
throw new Error('Hooks can only be called inside function components');
}
const current = currentlyRenderingFiber.alternate;
// 現在のフックを取得
let hook: Hook;
if (current) {
// 更新時: 前回のフックを再利用
const oldHook = workInProgressHook
? workInProgressHook.next
: current.memoizedState;
hook = {
state: oldHook ? oldHook.state : null,
queue: { pending: null },
next: null
};
} else {
// マウント時: 新しいフックを作成
hook = {
state: null,
queue: { pending: null },
next: null
};
}
// フックをリンクリストに追加
if (workInProgressHook) {
workInProgressHook.next = hook;
} else {
// 最初のフック
currentlyRenderingFiber.memoizedState = hook;
}
workInProgressHook = hook;
return hook;
};
// 更新をキューに追加
export const enqueueUpdate = <A>(hook: Hook, action: A): void => {
const update: Update = {
action,
next: null
};
// 更新をキューに追加
if (!hook.queue) {
hook.queue = { pending: null };
}
const pending = hook.queue.pending;
if (!pending) {
// 最初の更新
update.next = update;
} else {
// 既存の更新に追加
update.next = pending.next;
pending.next = update;
}
hook.queue.pending = update;
};
// 更新を処理
export const processUpdates = <S>(hook: Hook, initialState: S): S => {
const queue = hook.queue;
if (!queue || !queue.pending) {
return hook.state || initialState;
}
// 更新を適用
let state = hook.state || initialState;
let update = queue.pending.next;
let first = queue.pending.next;
do {
// 更新関数または新しい状態
const action = update.action;
state = typeof action === 'function' ? action(state) : action;
update = update.next;
} while (update !== first);
// キューをリセット
queue.pending = null;
// 新しい状態を返す
hook.state = state;
return state;
};3. useState Hookの実装
次に、最も基本的なHookであるuseStateを実装します:
typescript
// hooks/useState.ts
import { nextHook, enqueueUpdate, processUpdates } from './core';
import { Dispatch } from './types';
// useState Hook
export function useState<S>(initialState: S | (() => S)): [S, Dispatch<S | ((prevState: S) => S)>] {
// フックを取得
const hook = nextHook();
// 初期状態の解決
const resolvedInitialState = typeof initialState === 'function'
? (initialState as () => S)()
: initialState;
// 状態を処理
const state = processUpdates(hook, resolvedInitialState);
// 状態更新関数
const setState: Dispatch<S | ((prevState: S) => S)> = (action) => {
enqueueUpdate(hook, action);
// 再レンダリングをスケジュール
scheduleUpdate(currentlyRenderingFiber);
};
return [state, setState];
}
// 再レンダリングをスケジュールする関数(実際の実装はFiberモジュールと連携)
const scheduleUpdate = (fiber: Fiber | null): void => {
if (fiber) {
// Fiberの更新をスケジュール
// この部分は実際にはFiberモジュールと連携
}
};4. useEffect Hookの実装
次に、副作用を扱うuseEffectを実装します:
typescript
// hooks/useEffect.ts
import { nextHook } from './core';
import { Effect, EffectTag } from './types';
import { Fiber } from '../fiber/types';
// 現在のFiberの副作用リスト
let lastEffect: Effect | null = null;
// useEffect Hook
export function useEffect(
create: () => void | (() => void),
deps?: any[]
): void {
// フックを取得
const hook = nextHook();
// 前回の依存配列と比較
const prevDeps = hook.state ? hook.state.deps : null;
const hasNoDeps = !deps;
const hasChangedDeps = prevDeps
? !deps || deps.some((dep, i) => dep !== prevDeps[i])
: true;
// 依存配列が変更された場合のみ副作用を実行
if (hasNoDeps || hasChangedDeps) {
// 副作用を作成
const effect: Effect = {
tag: EffectTag.PassiveEffect,
create,
destroy: null,
deps,
next: null
};
// 副作用をリストに追加
if (lastEffect) {
lastEffect.next = effect;
} else {
// 最初の副作用
currentlyRenderingFiber.updateQueue = effect;
}
lastEffect = effect;
// 状態を更新
hook.state = { deps };
}
}
// 副作用を実行する関数(コミットフェーズで呼び出される)
export const runEffects = (fiber: Fiber): void => {
// 前回の副作用をクリーンアップ
if (fiber.alternate) {
const effects = getEffectList(fiber.alternate);
effects.forEach(effect => {
if (effect.destroy) {
effect.destroy();
}
});
}
// 新しい副作用を実行
const effects = getEffectList(fiber);
effects.forEach(effect => {
const cleanup = effect.create();
effect.destroy = typeof cleanup === 'function' ? cleanup : null;
});
};
// 副作用リストを取得
const getEffectList = (fiber: Fiber): Effect[] => {
const effects: Effect[] = [];
let effect = fiber.updateQueue as Effect | null;
while (effect) {
effects.push(effect);
effect = effect.next;
}
return effects;
};テストの作成
テスト駆動開発のアプローチに従い、Hooksのテストを作成します:
typescript
import { createElement } from '../src/createElement';
import { createFiberRoot } from '../src/fiber/core';
import { scheduleWork } from '../src/fiber/update';
import { useState } from '../src/hooks/useState';
import { useEffect } from '../src/hooks/useEffect';
describe('hooks', () => {
beforeEach(() => {
// テスト用のDOM環境をリセット
document.body.innerHTML = '';
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('useState が正しく動作すること', () => {
const container = document.createElement('div');
document.body.appendChild(container);
// カウンターコンポーネント
const Counter = () => {
const [count, setCount] = useState(0);
return createElement(
'div',
null,
createElement('p', { id: 'count' }, `Count: ${count}`),
createElement('button', {
id: 'increment',
onClick: () => setCount(count + 1)
}, 'Increment')
);
};
// レンダリング
const fiberRoot = createFiberRoot(container);
scheduleWork(fiberRoot, createElement(Counter));
jest.runAllTimers();
// 初期状態の確認
expect(container.querySelector('#count')?.textContent).toBe('Count: 0');
// ボタンクリックで状態を更新
const button = container.querySelector('#increment') as HTMLButtonElement;
button.click();
jest.runAllTimers();
// 更新後の状態を確認
expect(container.querySelector('#count')?.textContent).toBe('Count: 1');
});
it('useEffect が正しく動作すること', () => {
const container = document.createElement('div');
document.body.appendChild(container);
// モック関数
const effectFn = jest.fn();
const cleanupFn = jest.fn();
// 副作用を持つコンポーネント
const EffectComponent = ({ value }: { value: string }) => {
useEffect(() => {
effectFn(value);
return () => cleanupFn(value);
}, [value]);
return createElement('div', null, `Value: ${value}`);
};
// 初回レンダリング
const fiberRoot = createFiberRoot(container);
scheduleWork(fiberRoot, createElement(EffectComponent, { value: 'initial' }));
jest.runAllTimers();
// 副作用が実行されたことを確認
expect(effectFn).toHaveBeenCalledWith('initial');
expect(cleanupFn).not.toHaveBeenCalled();
// 再レンダリング(値が変わる)
scheduleWork(fiberRoot, createElement(EffectComponent, { value: 'updated' }));
jest.runAllTimers();
// クリーンアップと新しい副作用が実行されたことを確認
expect(cleanupFn).toHaveBeenCalledWith('initial');
expect(effectFn).toHaveBeenCalledWith('updated');
// 再レンダリング(値が同じ)
scheduleWork(fiberRoot, createElement(EffectComponent, { value: 'updated' }));
jest.runAllTimers();
// 依存配列が変わっていないので副作用は再実行されない
expect(effectFn).toHaveBeenCalledTimes(2);
expect(cleanupFn).toHaveBeenCalledTimes(1);
});
it('複数のフックが正しく動作すること', () => {
const container = document.createElement('div');
document.body.appendChild(container);
// 複数のフックを使用するコンポーネント
const MultipleHooks = () => {
const [count, setCount] = useState(0);
const [text, setText] = useState('Hello');
useEffect(() => {
if (count > 0) {
setText(`Clicked ${count} times`);
}
}, [count]);
return createElement(
'div',
null,
createElement('p', { id: 'text' }, text),
createElement('button', {
id: 'button',
onClick: () => setCount(count + 1)
}, 'Click me')
);
};
// レンダリング
const fiberRoot = createFiberRoot(container);
scheduleWork(fiberRoot, createElement(MultipleHooks));
jest.runAllTimers();
// 初期状態の確認
expect(container.querySelector('#text')?.textContent).toBe('Hello');
// ボタンクリック
const button = container.querySelector('#button') as HTMLButtonElement;
button.click();
jest.runAllTimers();
// 更新後の状態を確認
expect(container.querySelector('#text')?.textContent).toBe('Clicked 1 times');
});
});Hooksの動作原理
Hooksがどのように動作するかを図示します:
実際の使用例
Hooksを使用して、簡単なカウンターアプリケーションを実装してみましょう:
typescript
import { createElement } from './createElement';
import { createFiberRoot } from './fiber/core';
import { scheduleWork } from './fiber/update';
import { useState, useEffect } from './hooks';
// カウンターアプリケーション
const Counter = () => {
// 状態の定義
const [count, setCount] = useState(0);
const [theme, setTheme] = useState('light');
// 副作用の定義
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
// テーマ切り替え
const toggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light');
};
// スタイルの定義
const styles = {
container: {
backgroundColor: theme === 'light' ? '#ffffff' : '#333333',
color: theme === 'light' ? '#333333' : '#ffffff',
padding: '20px',
borderRadius: '5px'
}
};
// UIの定義
return createElement(
'div',
{ style: styles.container },
createElement('h1', null, `Count: ${count}`),
createElement('button', {
onClick: () => setCount(count + 1)
}, 'Increment'),
createElement('button', {
onClick: () => setCount(count - 1)
}, 'Decrement'),
createElement('button', {
onClick: toggleTheme
}, `Switch to ${theme === 'light' ? 'dark' : 'light'} theme`)
);
};
// アプリケーションのレンダリング
const container = document.getElementById('root');
if (container) {
const fiberRoot = createFiberRoot(container);
scheduleWork(fiberRoot, createElement(Counter));
}次のステップ
Hooksの実装により、関数コンポーネントでも状態管理や副作用を扱えるようになりました。次の章では、コンポーネントツリーを超えたデータ共有を実現する「Context」を実装していきます。