Skip to content

第6章 Hooks

関数コンポーネントでの状態管理

前章では、レンダリング処理を小さな単位に分割し非同期で実行する「Fiber」アーキテクチャを実装しました。この章では、関数コンポーネントで状態管理や副作用を扱うための「Hooks」を実装していきます。

Hooksとは何か

Hooksは、React 16.8で導入された機能で、関数コンポーネントでも状態(state)やライフサイクルなどの機能を使えるようにするものです。これにより、クラスコンポーネントを使わずに、より簡潔で再利用しやすいコードを書くことができます。

Hooksの基本ルール

Hooksを使う際には、以下の2つのルールを守る必要があります:

  1. トップレベルでのみ呼び出す: 条件分岐やループ、ネストした関数の中でHooksを呼び出してはいけません
  2. 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」を実装していきます。

Released under the MIT License.