10日で覚えるReactDay 10: カスタムフックとスタイリング

Day 10: カスタムフックとスタイリング

今日学ぶこと

  • カスタムフックの作成方法
  • よく使われるカスタムフックパターン
  • CSSモジュール
  • CSS-in-JS(styled-components)
  • Tailwind CSS

カスタムフックとは

カスタムフックは、コンポーネント間でロジックを再利用するための仕組みです。名前は必ずuseで始めます。

flowchart TB
    subgraph CustomHook["カスタムフックの利点"]
        A["ロジックの再利用"]
        B["関心の分離"]
        C["テストしやすさ"]
        D["コードの整理"]
    end

    style A fill:#3b82f6,color:#fff
    style B fill:#8b5cf6,color:#fff
    style C fill:#22c55e,color:#fff
    style D fill:#f59e0b,color:#fff

基本的な例

import { useState, useEffect } from 'react';

// カスタムフック: ウィンドウサイズを取得
function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });

  useEffect(() => {
    function handleResize() {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    }

    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return size;
}

// 使用例
function ResponsiveComponent() {
  const { width, height } = useWindowSize();

  return (
    <div>
      <p>幅: {width}px</p>
      <p>高さ: {height}px</p>
      {width < 768 ? <MobileLayout /> : <DesktopLayout />}
    </div>
  );
}
TypeScript版
import { useState, useEffect } from 'react';

interface WindowSize {
  width: number;
  height: number;
}

// カスタムフック: ウィンドウサイズを取得
function useWindowSize(): WindowSize {
  const [size, setSize] = useState<WindowSize>({
    width: window.innerWidth,
    height: window.innerHeight
  });

  useEffect(() => {
    function handleResize(): void {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    }

    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return size;
}

// 使用例
function ResponsiveComponent(): React.JSX.Element {
  const { width, height } = useWindowSize();

  return (
    <div>
      <p>幅: {width}px</p>
      <p>高さ: {height}px</p>
      {width < 768 ? <MobileLayout /> : <DesktopLayout />}
    </div>
  );
}

よく使われるカスタムフックパターン

useToggle - トグル状態

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);

  const toggle = useCallback(() => {
    setValue(prev => !prev);
  }, []);

  const setTrue = useCallback(() => setValue(true), []);
  const setFalse = useCallback(() => setValue(false), []);

  return { value, toggle, setTrue, setFalse };
}

// 使用例
function Modal() {
  const { value: isOpen, toggle, setFalse: close } = useToggle();

  return (
    <>
      <button onClick={toggle}>Open Modal</button>
      {isOpen && (
        <div className="modal">
          <p>Modal Content</p>
          <button onClick={close}>Close</button>
        </div>
      )}
    </>
  );
}
TypeScript版
interface UseToggleReturn {
  value: boolean;
  toggle: () => void;
  setTrue: () => void;
  setFalse: () => void;
}

function useToggle(initialValue: boolean = false): UseToggleReturn {
  const [value, setValue] = useState<boolean>(initialValue);

  const toggle = useCallback((): void => {
    setValue(prev => !prev);
  }, []);

  const setTrue = useCallback(() => setValue(true), []);
  const setFalse = useCallback(() => setValue(false), []);

  return { value, toggle, setTrue, setFalse };
}

// 使用例
function Modal(): React.JSX.Element {
  const { value: isOpen, toggle, setFalse: close } = useToggle();

  return (
    <>
      <button onClick={toggle}>Open Modal</button>
      {isOpen && (
        <div className="modal">
          <p>Modal Content</p>
          <button onClick={close}>Close</button>
        </div>
      )}
    </>
  );
}

useLocalStorage - ローカルストレージ

function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  });

  const setValue = useCallback((value) => {
    try {
      const valueToStore = value instanceof Function
        ? value(storedValue)
        : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(error);
    }
  }, [key, storedValue]);

  return [storedValue, setValue];
}

// 使用例
function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <select value={theme} onChange={(e) => setTheme(e.target.value)}>
      <option value="light">ライト</option>
      <option value="dark">ダーク</option>
    </select>
  );
}
TypeScript版
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  });

  const setValue = useCallback((value: T | ((prev: T) => T)): void => {
    try {
      const valueToStore = value instanceof Function
        ? value(storedValue)
        : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(error);
    }
  }, [key, storedValue]);

  return [storedValue, setValue];
}

// 使用例
function Settings(): React.JSX.Element {
  const [theme, setTheme] = useLocalStorage<string>('theme', 'light');

  return (
    <select value={theme} onChange={(e) => setTheme(e.target.value)}>
      <option value="light">ライト</option>
      <option value="dark">ダーク</option>
    </select>
  );
}

useFetch - データフェッチング

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    async function fetchData() {
      try {
        setLoading(true);
        setError(null);

        const response = await fetch(url, {
          signal: controller.signal
        });

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        const json = await response.json();
        setData(json);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    }

    fetchData();

    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}

// 使用例
function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);

  if (loading) return <p>読み込み中...</p>;
  if (error) return <p>エラー: {error}</p>;

  return <h1>{user.name}</h1>;
}
TypeScript版
interface UseFetchReturn<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
}

function useFetch<T>(url: string): UseFetchReturn<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const controller = new AbortController();

    async function fetchData(): Promise<void> {
      try {
        setLoading(true);
        setError(null);

        const response = await fetch(url, {
          signal: controller.signal
        });

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        const json: T = await response.json();
        setData(json);
      } catch (err) {
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    }

    fetchData();

    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}

// 使用例
interface User {
  name: string;
}

interface UserProfileProps {
  userId: string;
}

function UserProfile({ userId }: UserProfileProps): React.JSX.Element {
  const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);

  if (loading) return <p>読み込み中...</p>;
  if (error) return <p>エラー: {error}</p>;

  return <h1>{user!.name}</h1>;
}

useDebounce - 入力の遅延処理

function useDebounce(value, delay = 500) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

// 使用例
function SearchInput() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (debouncedQuery) {
      console.log('検索:', debouncedQuery);
      // API呼び出し
    }
  }, [debouncedQuery]);

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="検索..."
    />
  );
}
TypeScript版
function useDebounce<T>(value: T, delay: number = 500): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

// 使用例
function SearchInput(): React.JSX.Element {
  const [query, setQuery] = useState<string>('');
  const debouncedQuery = useDebounce<string>(query, 300);

  useEffect(() => {
    if (debouncedQuery) {
      console.log('検索:', debouncedQuery);
      // API呼び出し
    }
  }, [debouncedQuery]);

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="検索..."
    />
  );
}

Reactでのスタイリング

Reactでは様々なスタイリング方法があります。

flowchart TB
    subgraph Styling["スタイリング手法"]
        A["インラインスタイル"]
        B["CSSファイル"]
        C["CSSモジュール"]
        D["CSS-in-JS"]
        E["Tailwind CSS"]
    end

    style A fill:#ef4444,color:#fff
    style B fill:#f59e0b,color:#fff
    style C fill:#3b82f6,color:#fff
    style D fill:#8b5cf6,color:#fff
    style E fill:#22c55e,color:#fff
手法 特徴 適した用途
インラインスタイル シンプル、動的 小さな動的スタイル
CSSファイル 伝統的、グローバル 小規模プロジェクト
CSSモジュール スコープ付き 中〜大規模プロジェクト
CSS-in-JS JSと統合 コンポーネントライブラリ
Tailwind CSS ユーティリティファースト 迅速な開発

CSSモジュール

CSSモジュールは、CSSクラス名を自動的にスコープ化します。

セットアップ

Viteでは追加設定なしで使用できます。ファイル名を.module.cssにするだけです。

使用例

/* Button.module.css */
.button {
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.primary {
  background-color: #3b82f6;
  color: white;
}

.secondary {
  background-color: #6b7280;
  color: white;
}

.danger {
  background-color: #ef4444;
  color: white;
}
// Button.jsx
import styles from './Button.module.css';

function Button({ variant = 'primary', children, ...props }) {
  return (
    <button
      className={`${styles.button} ${styles[variant]}`}
      {...props}
    >
      {children}
    </button>
  );
}

// 使用例
<Button variant="primary">送信</Button>
<Button variant="danger">削除</Button>
TypeScript版
// Button.tsx
import styles from './Button.module.css';

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'danger';
  children: React.ReactNode;
}

function Button({ variant = 'primary', children, ...props }: ButtonProps): React.JSX.Element {
  return (
    <button
      className={`${styles.button} ${styles[variant]}`}
      {...props}
    >
      {children}
    </button>
  );
}

動的クラス名

import styles from './Card.module.css';

function Card({ isActive, children }) {
  const cardClass = [
    styles.card,
    isActive && styles.active
  ].filter(Boolean).join(' ');

  return <div className={cardClass}>{children}</div>;
}
TypeScript版
import styles from './Card.module.css';

interface CardProps {
  isActive: boolean;
  children: React.ReactNode;
}

function Card({ isActive, children }: CardProps): React.JSX.Element {
  const cardClass = [
    styles.card,
    isActive && styles.active
  ].filter(Boolean).join(' ');

  return <div className={cardClass}>{children}</div>;
}

CSS-in-JS (styled-components)

styled-componentsは、JavaScriptの中でCSSを書く人気のライブラリです。

インストール

npm install styled-components

基本的な使い方

import styled from 'styled-components';

// スタイル付きコンポーネントを作成
const Button = styled.button`
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  background-color: ${props => props.$primary ? '#3b82f6' : '#6b7280'};
  color: white;

  &:hover {
    opacity: 0.9;
  }
`;

// 使用例
function App() {
  return (
    <>
      <Button $primary>Primary Button</Button>
      <Button>Secondary Button</Button>
    </>
  );
}
TypeScript版
import styled from 'styled-components';

interface ButtonProps {
  $primary?: boolean;
}

// スタイル付きコンポーネントを作成
const Button = styled.button<ButtonProps>`
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  background-color: ${props => props.$primary ? '#3b82f6' : '#6b7280'};
  color: white;

  &:hover {
    opacity: 0.9;
  }
`;

// 使用例
function App(): React.JSX.Element {
  return (
    <>
      <Button $primary>Primary Button</Button>
      <Button>Secondary Button</Button>
    </>
  );
}

既存コンポーネントの拡張

const BaseButton = styled.button`
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
`;

// 拡張
const PrimaryButton = styled(BaseButton)`
  background-color: #3b82f6;
  color: white;
`;

const DangerButton = styled(BaseButton)`
  background-color: #ef4444;
  color: white;
`;

テーマの使用

import styled, { ThemeProvider } from 'styled-components';

const theme = {
  colors: {
    primary: '#3b82f6',
    secondary: '#6b7280',
    danger: '#ef4444'
  },
  spacing: {
    small: '8px',
    medium: '16px',
    large: '24px'
  }
};

const Button = styled.button`
  padding: ${props => props.theme.spacing.medium};
  background-color: ${props => props.theme.colors.primary};
  color: white;
`;

function App() {
  return (
    <ThemeProvider theme={theme}>
      <Button>Themed Button</Button>
    </ThemeProvider>
  );
}
TypeScript版
import styled, { ThemeProvider } from 'styled-components';

interface Theme {
  colors: {
    primary: string;
    secondary: string;
    danger: string;
  };
  spacing: {
    small: string;
    medium: string;
    large: string;
  };
}

const theme: Theme = {
  colors: {
    primary: '#3b82f6',
    secondary: '#6b7280',
    danger: '#ef4444'
  },
  spacing: {
    small: '8px',
    medium: '16px',
    large: '24px'
  }
};

const Button = styled.button`
  padding: ${(props: { theme: Theme }) => props.theme.spacing.medium};
  background-color: ${(props: { theme: Theme }) => props.theme.colors.primary};
  color: white;
`;

function App(): React.JSX.Element {
  return (
    <ThemeProvider theme={theme}>
      <Button>Themed Button</Button>
    </ThemeProvider>
  );
}

Tailwind CSS

Tailwind CSSは、ユーティリティファーストのCSSフレームワークです。

セットアップ(Vite)

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
// tailwind.config.js
export default {
  content: [
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
/* index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

基本的な使い方

function Button({ variant = 'primary', children }) {
  const baseClasses = "px-4 py-2 rounded font-medium";

  const variantClasses = {
    primary: "bg-blue-500 text-white hover:bg-blue-600",
    secondary: "bg-gray-500 text-white hover:bg-gray-600",
    danger: "bg-red-500 text-white hover:bg-red-600",
  };

  return (
    <button className={`${baseClasses} ${variantClasses[variant]}`}>
      {children}
    </button>
  );
}

// 使用例
<Button variant="primary">送信</Button>
TypeScript版
interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'danger';
  children: React.ReactNode;
}

function Button({ variant = 'primary', children }: ButtonProps): React.JSX.Element {
  const baseClasses = "px-4 py-2 rounded font-medium";

  const variantClasses: Record<string, string> = {
    primary: "bg-blue-500 text-white hover:bg-blue-600",
    secondary: "bg-gray-500 text-white hover:bg-gray-600",
    danger: "bg-red-500 text-white hover:bg-red-600",
  };

  return (
    <button className={`${baseClasses} ${variantClasses[variant]}`}>
      {children}
    </button>
  );
}

レスポンシブデザイン

function Card() {
  return (
    <div className="p-4 md:p-6 lg:p-8">
      <h2 className="text-lg md:text-xl lg:text-2xl font-bold">
        タイトル
      </h2>
      <p className="text-sm md:text-base text-gray-600">
        説明文がここに入ります。
      </p>
    </div>
  );
}

条件付きスタイル

function Alert({ type = 'info', children }) {
  const classes = {
    info: 'bg-blue-100 text-blue-800 border-blue-300',
    success: 'bg-green-100 text-green-800 border-green-300',
    warning: 'bg-yellow-100 text-yellow-800 border-yellow-300',
    error: 'bg-red-100 text-red-800 border-red-300',
  };

  return (
    <div className={`p-4 border rounded ${classes[type]}`}>
      {children}
    </div>
  );
}
TypeScript版
interface AlertProps {
  type?: 'info' | 'success' | 'warning' | 'error';
  children: React.ReactNode;
}

function Alert({ type = 'info', children }: AlertProps): React.JSX.Element {
  const classes: Record<string, string> = {
    info: 'bg-blue-100 text-blue-800 border-blue-300',
    success: 'bg-green-100 text-green-800 border-green-300',
    warning: 'bg-yellow-100 text-yellow-800 border-yellow-300',
    error: 'bg-red-100 text-red-800 border-red-300',
  };

  return (
    <div className={`p-4 border rounded ${classes[type]}`}>
      {children}
    </div>
  );
}

スタイリング手法の比較

基準 CSSモジュール styled-components Tailwind
学習曲線
バンドルサイズ 小〜中
ランタイムコスト なし あり なし
動的スタイル 難しい 簡単 可能
エディタ補完 良好 良好 優秀

プロジェクトに応じた選択

flowchart TB
    A["プロジェクト開始"] --> B{"チームのCSS経験"}
    B -->|"従来のCSS"| C["CSSモジュール"]
    B -->|"JSファースト"| D["styled-components"]
    B -->|"迅速な開発"| E["Tailwind CSS"]

    style C fill:#3b82f6,color:#fff
    style D fill:#8b5cf6,color:#fff
    style E fill:#22c55e,color:#fff

10日間のまとめ

おめでとうございます!10日間でReactの基礎を学び終えました。

学んだこと

Day トピック 主なポイント
1 Reactへようこそ 宣言的UI、コンポーネント、Vite
2 JSXを理解する JSX構文、条件付きレンダリング、リスト
3 コンポーネントとProps Props、children、コンポーネント合成
4 Stateとイベント useState、イベントハンドラー
5 フォームの処理 制御/非制御コンポーネント
6 副作用とuseEffect useEffect、クリーンアップ、データフェッチ
7 RefsとPortals useRef、forwardRef、createPortal
8 ContextとState管理 Context API、useReducer
9 パフォーマンス最適化 memo、useMemo、useCallback、lazy
10 カスタムフックとスタイリング カスタムフック、CSSモジュール、Tailwind

次のステップ

  1. 実践プロジェクト: Todoアプリ、ブログ、ECサイトなど
  2. テスト: Jest、React Testing Library
  3. 状態管理: Zustand、Redux Toolkit
  4. フレームワーク: Next.js、Remix
  5. TypeScript: 型安全なReact開発

練習問題

問題1: 基本

useCounterカスタムフックを作成してください。増加、減少、リセット機能と、最小/最大値の制限を持たせてください。

問題2: 応用

ダークモード切り替え機能を実装してください:

  • useDarkModeカスタムフック(ローカルストレージに保存)
  • Tailwind CSSでダークモードスタイル
  • トグルボタン

チャレンジ問題

完全なTodoアプリを作成してください:

  • カスタムフック(useTodos)でロジック管理
  • ローカルストレージに永続化
  • CSSモジュールまたはTailwindでスタイリング
  • フィルター機能(全て/完了/未完了)
  • アニメーション付き

参考リンク


おわりに

10日間のReact学習、お疲れさまでした。

この書籍で学んだ内容は、Reactの基礎として非常に重要です。しかし、真のスキルは実践を通じて身につきます。

学んだことを活かして、自分のプロジェクトを作ってみてください。

失敗を恐れず、たくさんのコードを書いて、Reactマスターへの道を歩んでいきましょう!