🫠

状態管理ライブラリを使わずに Todo CRUD を段階的に実装してみた(useState→Hooks→Context→キャッシュ)

に公開

背景

普段 React / Next.js で SWRTanStack Query を使ってデータの fetch や状態管理を行っています。

ただ、最初からライブラリを使用していたため、

  • データを更新した時の同期
  • キャッシュの恩恵

といった「便利さの中身」を、あまり良く実感できていませんでした。

そこで今回は ライブラリを使わずに最低限の実装をしてみて、状態管理の設計を段階的に進化させてみました。


技術構成

フロント(状態管理と処理に関わる部分のみ抜粋)

  • react 19.2.4
  • TypeScript 5系
  • zod 4.3.6
  • react-hook-form 7.71.1
  • @supabase/supabase-js 2.94.1

DB

  • Supabase

実装の順序

いきなりすべての機能を実装するのではなく、最小構成から段々と肉付けする流れでやっていきました。

  1. useState・処理関数、Propsによる受け渡しのみ(キャッシュなし)
  2. カスタムhooksの作成とPropsによる受け渡しのみ(キャッシュなし)
  3. useContextでStateのみ共有、処理関数はカスタムhooksから呼び出し(キャッシュなし)
  4. useContextでStateとActionを分離して状態管理(キャッシュあり)

やった結果

実際に1つずつ実装を進めていくと、処理の関心を分離することによって UI側での認知負担が減ったり、エラーの原因を探るのが容易になりました。

また最後の「state と action の分離」のタイミングで、共通処理を作成したときに コードの見やすさがかなり良くなったのが印象的でした。


共通コード

API関連定義
src/lib/api.ts
import type { PostgrestError } from "@supabase/supabase-js";

export type Status = "idle" | "loading" | "error" | "success";
export type LocalCache<T> = {
  savedAt: string;
  data: T;
};
export type ApiSuccess<T> = {
  ok: true;
  data: T;
};

export type ApiFailure<E> = {
  ok: false;
  error: E;
};

export type ApiResult<T, E = PostgrestError> = ApiSuccess<T> | ApiFailure<E>;

export type ApiResponse<T, E = PostgrestError> = Promise<ApiResult<T, E>>;
Todo型定義(Zod)
src/features/todo/types/todo.ts
import z from "zod";
import type { Database } from "@/type/schema";
type DbTodoStatus = Database["public"]["Enums"]["todos_status"];
export const todoStatusValues = ["TODO", "PROGRESS", "COMPLETE"] as const satisfies readonly DbTodoStatus[];
export const todoStatusSchema = z.enum(todoStatusValues);
export const todoSchema = z.object({
  id: z.uuid(),
  title: z.string().min(1, "タイトルを入力してください。"),
  contents: z.string().max(100, "文字は100文字までです。").optional().nullable(),
  due_date: z.string(),
  status: todoStatusSchema,
  updated_at: z.string(),
  created_at: z.string(),
});
export const createTodoSchema = z.object({
  title: z.string().min(1, "タイトルを入力してください。"),
  contents: z.string().max(100, "文字は100文字までです。").optional(),
  due_date: z.string().optional(),
});
export const insertTodoSchema = z.object({
  title: z.string().min(1, "タイトルを入力してください。"),
  contents: z.string().optional().nullable(),
  due_date: z.string().optional(),
});
export const updateTodoFormSchema = createTodoSchema.extend({
  status: todoStatusSchema,
});
export const updateTodoSchema = updateTodoFormSchema.extend({
  id: z.uuid(),
});
export const updateTodoStatusSchema = todoSchema.pick({
  id: true,
  status: true,
});
export const deleteTodoSchema = todoSchema.pick({
  id: true,
});

export type TodoStatus = z.infer<typeof todoStatusSchema>;
export type Todo = z.infer<typeof todoSchema>;
export type InsertTodo = z.infer<typeof insertTodoSchema>;
export type CreateTodo = z.infer<typeof createTodoSchema>;
export type UpdateTodo = z.input<typeof updateTodoSchema>;
export type UpdateTodoForm = z.input<typeof updateTodoFormSchema>;
export type DeleteTodo = z.input<typeof deleteTodoSchema>;
export type UpdateTodoStatus = z.infer<typeof updateTodoStatusSchema>;


1. useState・処理関数、Propsによる受け渡しのみ(キャッシュなし)

まず単純にデータを取得して CRUD 操作をするための実装をしました。

手順としては以下です。

1. useStateで状態管理を定義

const [todos,setTodos] = useState<Todo[] | null>(null);
const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);

2. useEffectとfetchを使用して、setTodosに値の受け渡し

useEffect(() => {
  const fetcTodos = async () => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }
    const contoller = new AbortController();
    abortControllerRef.current = contoller;
    try {
      const payload = await api.getTodos({ signal: contoller.signal });
      if (payload.ok) {
        setStatus("success");
        setTodos(data);
      } else {
        setStatus("error");
        setError("データの取得に失敗しました。");
      }
    } catch (e) {
      if (e instanceof DOMException && e.name === "AbortError") {
        return;
      }
      setStatus("error");
      setError(e instanceof Error ? e.message : "unknown");
    }
  };
  fetchTodos();
  return () => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }
  };
}, []);

3. 更新関数の定義

長いので折りたたみ
<!-- Create -->
 const createTodo = useCallback(async (data: CreateTodo): Promise<ResultResponse> => {
       const tentativeId = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `temp-${Math.random().toString(16).slice(2)}`;
       const insertData: InsertTodo = {
         ...data,
         ...(data.contents === "" ? { contents: undefined } : {}),
         ...(data.due_date === "" ? { due_date: undefined } : {}),
       }; 
       const tentativeData: Todo = {
         id: tentativeId,
         ...insertData,
         due_date: new Date().toISOString(),
         status: "TODO",
         updated_at: new Date().toISOString(),
         created_at: new Date().toISOString(),
       };
       setTodos((prev) => [tentativeData, ...prev]);
       try {
         const payload = await api.createTodo(insertData);
         if (payload.ok) {
           setTodos((prev) => {
            const removeTentativeData = prev.filter((i) => i.id !== id);
            const setData = [data, ...removeTentativeData];
               return setData;
            });
           setTodos()
           return { ok: true, message: "タスクの作成に成功しました。" };
         } else {
         setTodos((prev) => prev.filter((i) => i.id !== tentativeId););
           return { ok: false, message: "データの作成に失敗しました。" };
         }
       } catch (e) {
         setTodos((prev) => prev.filter((i) => i.id !== tentativeId););
         return { ok: false, error: e, message: "エラーが起きました。" };
       }
     },
     [setTodos],
   );
<!-- Update -->
const updateTodo = useCallback(async (data: UpdateTodo): Promise<ResultResponse> => {
       const before = todos.find((item) => item.id === data.id);
       if (!before) {
         return { ok: false, message: "データがないか、既に削除されたデータです。" };
       }
       const tentativeData = {
         ...before,
         title: data.title,
         status: data.status,
         ...(data.contents === "" ? { contents: undefined } : {}),
         ...(data.due_date === "" ? { due_date: undefined } : {}),
       };
       patchItemLocal(tentativeData.id, tentativeData);
       setTodos((prev) => prev.map((t) => (t.id === tentativeData.id ? { ...t, ...tentativeData } : t)););
       try {
         const payload = await api.updateTodo(data);
         if (payload.ok) {
           setTodos((prev) => prev.map((t) => (t.id === payload.data.id ? { ...t, ...payload.data } : t)););
           return { ok: true, message: "データの更新に成功しました。" };
         } else {
           setTodos((prev) => prev.map((t) => (t.id === before.id ? { ...t, ...before } : t)););
           return { ok: false, error: "データの更新に失敗しました。", message: "データの更新に失敗しました。" };
         }
       } catch (e) {
         setTodos((prev) => prev.map((t) => (t.id === before.id ? { ...t, ...before } : t)););
         return { ok: false, error: e, message: "エラーが起きました。" };
       }
     },
     [todos],
   );
<!-- Status Update -->
const updateTodoStatus = useCallback(async (data: UpdateTodoStatus): Promise<ResultResponse> => {
       const before = todos.find((item) => item.id === data.id);
       if (!before) {
         return { ok: false, message: "データがないか、既に削除されたデータです。" };
       }
       const tentativeData = {
         ...before,
         status: data.status,
       };
       setTodos((prev) => prev.map((t) => (t.id === tentativeData.id ? { ...t, ...tentativeData } : t)););
       try {
         const payload = await api.updateTodoStatus(data);
         if (payload.ok) {
              setTodos((prev) => prev.map((t) => (t.id === payload.data.id ? { ...t, ...payload.data } : t)););
           return { ok: true, message: "データの更新に成功しました。" };
         } else {
           setTodos((prev) => prev.map((t) => (t.id === before.id ? { ...t, ...before } : t)););
           return { ok: false, error: "データの更新に失敗しました。", message: "データの更新に失敗しました。" };
         }
       } catch (e) {
         setTodos((prev) => prev.map((t) => (t.id === before.id ? { ...t, ...before } : t)););
         return { ok: false, error: e, message: "エラーが起きました。" };
       }
     },
     [todos, patchItemLocal],
   );
<!-- Delete -->
const deleteTodo = useCallback(async (data: DeleteTodo): Promise<ResultResponse> => {
       const idx = todos.findIndex((t) => t.id === data.id);
       if (idx === -1) return { ok: false, message: "データがないか、既に削除されたデータです。" };
       const removedData = todos[idx];
       const after = todos.filter((t) => t.id !== data.id);
       setTodos(after);
       try {
         const payload = await api.deleteTodo(data);
         if (payload.ok) return { ok: true, message: "データの削除に成功しました。" };
         setTodos((prev) => {
           if (prev.some((t) => t.id === removedData.id)) return prev;
           const next = [...prev];
           const insertAt = Math.min(idx, next.length);
           next.splice(insertAt, 0, removedData);
           writeTodosCache(next);
           return next;
         });
         return { ok: false, error: "データの削除に失敗しました。", message: "データの削除に失敗しました。" };
       } catch (e) {
         setTodos((prev) => {
           if (prev.some((t) => t.id === removedData.id)) return prev;
           const next = [...prev];
           const insertAt = Math.min(idx, next.length);
           next.splice(insertAt, 0, removedData);
           writeTodosCache(next);
           return next;
         });
         return { ok: false, error: e, message: "エラーが起きました。" };
       }
     },
     [todos, setTodosLocal, restoreItemLocal],
   );

4. UI側での表示

const TodoList = () => {
  const [todos,setTodos] = useState<Todo[] | null>(null);
  <!-- その他fetch処理など  -->
  return (
    <div className="mt-5 grid gap-5 grid-cols-[repeat(auto-fit,minmax(300px,1fr))]">
      {todos.map((item) => (
        <TodoCard data={item} key={item.id} />
      ))}
    </div>
  );
};

感想

  • 実装はシンプルで扱いやすい
  • データの取得だけで、簡易ブログとかであればキャッシュを実装すれば十分なイメージ
  • CRUD内でのsetTodosが似たような処理が多くまとめたくなった。
  • 他コンポーネントで使用する場合、Propsで渡したりしないといけなかったりする。また離れている場合都度そのUIで呼び出しをしないといけない。

2. カスタムhooksの作成とPropsによる受け渡しのみ(キャッシュなし)

1 の方法では親コンポーネントで全て定義をしていたため、カスタムhooksを作成して、どのコンポーネントからでも読み込めるように変更をしていきました。

1. hooksファイルの作成

src/features/todo/hooks/useTodos.ts
const useTodos = () => {
  const [todos,setTodos] = useState<Todo[] | null>(null);
  const [status, setStatus] = useState<Status>("idle");
  const [error, setError] = useState<string | null>(null);
  const abortControllerRef = useRef<AbortController | null>(null);

  useEffect(()=> {
    <!-- 取得処理 -->
  },[])

  const createTodo = useCallback(async (data: CreateTodo): Promise<ResultResponse> => {
    <!-- 作成処理 -->
  },[setTodos],
  );

  const updateTodo = useCallback(async (data: UpdateTodo): Promise<ResultResponse> => {     
    <!-- 更新処理 -->
  },[setTodos],
  );

  const updateTodoStatus = useCallback(async (data: UpdateTodoStatus): Promise<ResultResponse> => {
    <!-- ステータス更新処理 -->
  },[setTodos],
  );

  const deleteTodo = useCallback(async (data: DeleteTodo): Promise<ResultResponse> =>
    <!-- 削除処理 -->
  },[setTodos],
  );

  return {
    todos,
    createTodo,
    updateTodo,
    updateTodoStatus,
    deleteTodo
  }
}
export default useTodos;

2. UI側での呼び出し

import useTodos from "@/features/todo/hooks/useTodos";
const TodoList = () => {
  const {todos} = useTodos();
  return (
    <div className="mt-5 grid gap-5 grid-cols-[repeat(auto-fit,minmax(300px,1fr))]">
      {todos.map((item) => (
        <TodoCard data={item} key={item.id} />
      ))}
    </div>
  );
};

interface Props {
  data: Todo;
}
const TodoCard = ({ data }: Props) => {
  const {updateTodoStatus, deleteTodo} = useTodos();
  return (
    <Card className="gap-2 py-3 h-max" key={data.id}>
      <!-- カードの表示やイベント関数の受け渡し  -->
    </Card>
  );
};

感想

  • 1よりも呼び出しがスッキリしてUIに集中できました。またreact-hook-formとの併用をしていると、どうしてもごちゃごちゃしていたので、処理の関心が分けれて良かった。
  • 処理を別の場所に分離することによって、原因の特定がわかりやすくなった。
  • サイト・アプリ全体で使用するデータではなく、特定のコンポーネント内だけで完結する場合などは便利かも。例えばタスクや記事などに特定のコメント機能がついている場合は、useHooksを作成して、各タスクや記事ごとでコメントのidを引数にhooksを呼び出せば、それぞれのデータが分離して更新が可能となる。
  • ただ、呼び出し元事でデータは分離しているので、一部でupdate関数を発火しても、他の箇所は更新されずにいる。その場合はどうしても親コンポーネントからPropsで渡さないといけない。

3. useContextでStateのみ共有、処理関数はカスタムhooksから呼び出し(キャッシュなし)

1,2 の課題であった「他コンポーネントでのデータ同期」の部分が今の構成では実現はできるが、後々を考えるとめんどくさいため useContext を導入して解決をすることにした。

色んな記事を見てみると、useContext と useReducer を使い dispatch で更新をする方法を見つけました。

しかし私自身初めて触れた Vue の Pinia に慣れすぎていて dispatch がどうも好きになれず、この段階では関数のみ useHooks に残して、hooks 内で context を呼び出して更新をしました。

1. Providerの作成

type TodoState = {
  todos: Todo[];
  setTodos:Dispatch<SetStateAction<Todo[]>>
  status: Status;
  setStatus:Dispatch<SetStateAction<Status>>
  error: string | null;
  setError:Dispatch<SetStateAction<string | null>>
};
export type ResultResponse = { ok: boolean; message: string; error?: unknown };
const StateContext = createContext<TodoState | undefined>(undefined);

export function TodosProvider({ children }: { children: React.ReactNode }) {
  /** State管理 */
  const [todos, setTodos] = useState<Todo[]>([]);
  const [status, setStatus] = useState<Status>("idle");
  const [error, setError] = useState<string | null>(null);
  const abortControllerRef = useRef<AbortController | null>(null);

  const stateValue = useMemo<TodoState>(() => ({ todos, setTodos,status,setStatus, error,setError }), [todos, setTodos,status,setStatus, error,setError]);

  return (
    <StateContext.Provider value={stateValue}>
      {children}
    </StateContext.Provider>
  );
}

export function useTodoState() {
  const s = useContext(StateContext);
  if (!s) throw new Error("useTodoState must be used within <TodoProvider>");
  return s;
}

2. hooks側で呼び出し

import { useTodoState } from "@/features/todo/provider/TodosProvider";
const useTodos = () => {
  const { todos,setTodos,setError,setStatus } = useTodoState();
  <!-- 処理関数 -->
}

3. UI側での呼び出し

import useTodos from "@/features/todo/hooks/useTodos";
import { useTodoState } from "@/features/todo/provider/TodosProvider";

const TodoList = () => {
  const {todos} = useTodoState();
  return (
    <div className="mt-5 grid gap-5 grid-cols-[repeat(auto-fit,minmax(300px,1fr))]">
      {todos.map((item) => (
        <TodoCard data={item} key={item.id} />
      ))}
    </div>
  );
};

interface Props {
  data: Todo;
}

const TodoCard = ({ data }: Props) => {
  const {updateTodoStatus, deleteTodo} = useTodos();
  return (
    <Card className="gap-2 py-3 h-max" key={data.id}>
      <!-- カードの表示やイベント関数の受け渡し  -->
    </Card>
  );
};

感想

  • コンポーネント間の同期が可能になり、無理にPropsを使わずとも反映が可能になった。
  • 他の参考記事などを見ると、無理にdispatchを使う必要もなく、関数をhooksとして呼び出してProviderに渡せば更に分離ができるとのこと
  • 今後キャッシュを導入していくにあたり、setTodosへの値反映など処理が被るので、このあたりも共通化していきたい。

4. useContextでStateとactionを分離して状態管理(キャッシュあり)

これが今回の最終形態です。ページネーションや大量データには対応していませんが、キャッシュとそれぞれの処理を分離したものになります。

仕様整理

  • ローカルストレージでTodoデータのキャッシュを取る。時間は定数で管理(cache)
  • UI側で楽観更新と同時にローカルストレージも更新したいため、setTodos内で一緒に更新できる関数を分離して作成する。(mutation)
  • 処理関数を別途Providerで作成をする。

1. キャッシュ関連ファイル

import type { LocalCache } from "@/lib/api";
import type { Todo } from "../types/todo";

const STORAGE_KEY = "todos";
const TTL_MS = 30000;
export function readTodosCache() {
  const cached = localStorage.getItem(STORAGE_KEY);
  return cached ? cached : null;
}
export function getTodosCache(cached: string | null) {
  if (cached) {
    return JSON.parse(cached) as LocalCache<Todo[]>;
  } else {
    return null;
  }
}

export function isFresh(cached: string | null): LocalCache<Todo[]> | null {
  if (cached) {
    const todosCached: LocalCache<Todo[]> = JSON.parse(cached);
    const savedAt = todosCached.savedAt;
    if (Date.now() - Number(savedAt) < TTL_MS && todosCached.data) {
      return todosCached;
    }
    return null;
  }
  return null;
}

export function writeTodosCache<T>(data: T, savedAt?: number) {
  localStorage.setItem(STORAGE_KEY, JSON.stringify({ data, savedAt: !savedAt ? Date.now() : savedAt }));
}

2. データ同期関連の関数

import { useCallback } from "react";
import type { Todo } from "../types/todo";
import { writeTodosCache } from "./todoCache";

const makeTodoLocalMutations = ({ setTodos }: { setTodos: React.Dispatch<React.SetStateAction<Todo[]>> }) => {
  const patchItemLocal = useCallback((id: Todo["id"], patch: Partial<Todo>) => {
    setTodos((prev) => {
      const setData = prev.map((t) => (t.id === id ? { ...t, ...patch } : t));
      writeTodosCache(setData);
      return setData;
    });
  }, []);

  // ローカル更新
  const setTodosLocal = useCallback(
    (data: Todo[]) => {
      setTodos(data);
      writeTodosCache(data);
    },
    [setTodos],
  );

  // ローカルデータの置換
  const replaceItemLocal = useCallback((id: Todo["id"], data: Todo) => {
    setTodos((prev) => {
      const removeTentativeData = prev.filter((i) => i.id !== id);
      const setData = [data, ...removeTentativeData];
      writeTodosCache(setData);

      return setData;
    });
  }, []);

  // ローカルデータの削除
  const removeItemLocal = useCallback((id: Todo["id"]) => {
    setTodos((prev) => {
      const removeIdData = prev.filter((i) => i.id !== id);
      writeTodosCache(removeIdData);
      return removeIdData;
    });
  }, []);

  // ローカルデータの復元
  const restoreItemLocal = useCallback((removedId: Todo["id"], originalPosition: number, removedData: Todo) => {
    setTodos((prev) => {
      if (prev.some((t) => t.id === removedId)) return prev;
      const next = [...prev];
      const insertAt = Math.min(originalPosition, next.length);
      next.splice(insertAt, 0, removedData);
      writeTodosCache(next);
      return next;
    });
  }, []);

  return {
    patchItemLocal,
    setTodosLocal,
    replaceItemLocal,
    removeItemLocal,
    restoreItemLocal,
  };
};
export default makeTodoLocalMutations;

3. 取得・更新処理関数

長いので折りたたみ
import { useCallback, useEffect, type Dispatch, type RefObject, type SetStateAction } from "react";
import type { Status } from "@/lib/api";
import * as api from "@/features/todo/services/todo";
import type { CreateTodo, DeleteTodo, InsertTodo, Todo, UpdateTodo, UpdateTodoStatus } from "../types/todo";
import { isFresh, readTodosCache } from "./todoCache";
import makeTodoLocalMutations from "./todoLocalMutations";
import type { ResultResponse } from "./TodosProvider";


interface MakeTodoActions {
  todos: Todo[];
  setTodos: Dispatch<SetStateAction<Todo[]>>;
  setStatus: Dispatch<SetStateAction<Status>>;
  setError: Dispatch<SetStateAction<string | null>>;
  abortControllerRef: RefObject<AbortController | null>;
}

const makeTodoActions = ({ todos, setTodos, setStatus, setError, abortControllerRef }: MakeTodoActions) => {
  const { patchItemLocal, setTodosLocal, replaceItemLocal, removeItemLocal, restoreItemLocal } = makeTodoLocalMutations({ setTodos });

  const refetch = useCallback(async () => {
    const cached = readTodosCache();
    const cacheData = isFresh(cached);

    // キャッシュがある場合そこから取得
    if (cacheData) {
      setTodos(cacheData.data);
      setStatus("success");
      return;
    }

    setStatus("loading");
    setError(null);

    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }

    const contoller = new AbortController();
    abortControllerRef.current = contoller;

    try {
      const payload = await api.getTodos({ signal: contoller.signal });
      if (payload.ok) {
        setStatus("success");
        setTodosLocal(payload.data);
      } else {
        setStatus("error");
        setError("データの取得に失敗しました。");
      }
    } catch (e) {
      if (e instanceof DOMException && e.name === "AbortError") {
        return;
      }
      setStatus("error");
      setError(e instanceof Error ? e.message : "unknown");
    }
  }, [setTodosLocal]);

  useEffect(() => {
    void refetch();
    return () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, [refetch]);

  const createTodo = useCallback(
    async (data: CreateTodo): Promise<ResultResponse> => {
      const tentativeId = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `temp-${Math.random().toString(16).slice(2)}`;

      const insertData: InsertTodo = {
        ...data,
        ...(data.contents === "" ? { contents: undefined } : {}),
        ...(data.due_date === "" ? { due_date: undefined } : {}),
      };

      const tentativeData: Todo = {
        id: tentativeId,
        ...insertData,
        due_date: new Date().toISOString(),
        status: "TODO",
        updated_at: new Date().toISOString(),
        created_at: new Date().toISOString(),
      };

      setTodos((prev) => [tentativeData, ...prev]);

      try {
        const payload = await api.createTodo(insertData);
        if (payload.ok) {
          replaceItemLocal(tentativeId, payload.data);
          return { ok: true, message: "タスクの作成に成功しました。" };
        } else {
          removeItemLocal(tentativeId);
          return { ok: false, message: "データの作成に失敗しました。" };
        }
      } catch (e) {
        removeItemLocal(tentativeId);
        return { ok: false, error: e, message: "エラーが起きました。" };
      }
    },
    [replaceItemLocal, removeItemLocal],
  );

  const updateTodo = useCallback(
    async (data: UpdateTodo): Promise<ResultResponse> => {
      const before = todos.find((item) => item.id === data.id);
      if (!before) {
        return { ok: false, message: "データがないか、既に削除されたデータです。" };
      }
      const tentativeData = {
        ...before,
        title: data.title,
        status: data.status,
        ...(data.contents === "" ? { contents: undefined } : {}),
        ...(data.due_date === "" ? { due_date: undefined } : {}),
      };
      patchItemLocal(tentativeData.id, tentativeData);

      try {
        const payload = await api.updateTodo(data);
        if (payload.ok) {
          patchItemLocal(payload.data.id, payload.data);
          return { ok: true, message: "データの更新に成功しました。" };
        } else {
          patchItemLocal(before.id, before);
          return { ok: false, error: "データの更新に失敗しました。", message: "データの更新に失敗しました。" };
        }
      } catch (e) {
        patchItemLocal(before.id, before);
        return { ok: false, error: e, message: "エラーが起きました。" };
      }
    },
    [todos, patchItemLocal],
  );

  const updateTodoStatus = useCallback(
    async (data: UpdateTodoStatus): Promise<ResultResponse> => {
      const before = todos.find((item) => item.id === data.id);
      if (!before) {
        return { ok: false, message: "データがないか、既に削除されたデータです。" };
      }
      const tentativeData = {
        ...before,
        status: data.status,
      };
      patchItemLocal(tentativeData.id, tentativeData);
      try {
        const payload = await api.updateTodoStatus(data);
        if (payload.ok) {
          patchItemLocal(payload.data.id, payload.data);
          return { ok: true, message: "データの更新に成功しました。" };
        } else {
          patchItemLocal(before.id, before);
          return { ok: false, error: "データの更新に失敗しました。", message: "データの更新に失敗しました。" };
        }
      } catch (e) {
        patchItemLocal(before.id, before);
        return { ok: false, error: e, message: "エラーが起きました。" };
      }
    },
    [todos, patchItemLocal],
  );

  const deleteTodo = useCallback(
    async (data: DeleteTodo): Promise<ResultResponse> => {
      const idx = todos.findIndex((t) => t.id === data.id);
      if (idx === -1) return { ok: false, message: "データがないか、既に削除されたデータです。" };
      const removedData = todos[idx];
      const after = todos.filter((t) => t.id !== data.id);
      setTodosLocal(after);
      try {
        const payload = await api.deleteTodo(data);
        if (payload.ok) return { ok: true, message: "データの削除に成功しました。" } 
        restoreItemLocal(removedData.id, idx, removedData);
        return { ok: false, error: "データの削除に失敗しました。", message: "データの削除に失敗しました。" };
      } catch (e) {
        // catchも同じく「対象だけ復元」
        restoreItemLocal(removedData.id, idx, removedData);
        return { ok: false, error: e, message: "エラーが起きました。" };
      }
    },
    [todos, setTodosLocal, restoreItemLocal],
  );

  return {
    refetch,
    createTodo,
    updateTodo,
    updateTodoStatus,
    deleteTodo,
  };
};
export default makeTodoActions;

4. Providerで呼び出し

import React, { createContext, useContext, useMemo, useRef, useState } from "react";
import type { CreateTodo, DeleteTodo, Todo, UpdateTodo, UpdateTodoStatus } from "@/features/todo/types/todo";
import type { Status } from "@/lib/api";

import makeTodoActions from "./todoActions";

type TodoState = {
  todos: Todo[];
  status: Status;
  error: string | null;
};
export type ResultResponse = { ok: boolean; message: string; error?: unknown };
export type TodoActions = {
  refetch: () => Promise<void>;
  createTodo: (data: CreateTodo) => Promise<ResultResponse>;
  updateTodo: (data: UpdateTodo) => Promise<ResultResponse>;
  updateTodoStatus: (data: UpdateTodoStatus) => Promise<ResultResponse>;
  deleteTodo: (data: DeleteTodo) => Promise<ResultResponse>;
};

const StateContext = createContext<TodoState | undefined>(undefined);
const ActionsContext = createContext<TodoActions | undefined>(undefined);

export function TodosProvider({ children }: { children: React.ReactNode }) {
  /** State管理 */
  const [todos, setTodos] = useState<Todo[]>([]);
  const [status, setStatus] = useState<Status>("idle");
  const [error, setError] = useState<string | null>(null);
  const abortControllerRef = useRef<AbortController | null>(null);

  const stateValue = useMemo<TodoState>(() => ({ todos, status, error }), [todos, status, error]);

  /** Action管理 */
  const { refetch, createTodo, updateTodo, updateTodoStatus, deleteTodo } = makeTodoActions({ todos, setTodos, setStatus, setError, abortControllerRef });
  const actionsValue = useMemo<TodoActions>(() => ({ refetch, createTodo, updateTodo, deleteTodo, updateTodoStatus }), [refetch, createTodo, updateTodo, deleteTodo, updateTodoStatus]);

  return (
    <StateContext.Provider value={stateValue}>
      <ActionsContext.Provider value={actionsValue}>{children}</ActionsContext.Provider>
    </StateContext.Provider>
  );
}

export function useTodoState() {
  const s = useContext(StateContext);
  if (!s) throw new Error("useTodoState must be used within <TodoProvider>");
  return s;
}

export function useTodoActions() {
  const a = useContext(ActionsContext);
  if (!a) throw new Error("useTodoActions must be used within <TodoProvider>");
  return a;
}

5. UI側での呼び出し

import { useTodoState } from "../provider/TodosProvider";
import { useTodoActions } from "../provider/TodosProvider";
import TodoCard from "./TodoCard";

const TodoList = () => {
  const { todos } = useTodoState();
  return (
    <div className="mt-5 grid gap-5 grid-cols-[repeat(auto-fit,minmax(300px,1fr))]">
      {todos.map((item) => (
        <TodoCard data={item} key={item.id} />
      ))}
    </div>
  );
};

const TodoCard = ({ data }: Props) => {
  const {updateTodoStatus, deleteTodo} = useTodoActions();
  return (
    <Card className="gap-2 py-3 h-max" key={data.id}>
      <!-- カードの表示やイベント関数の受け渡し  -->
    </Card>
  );
};

感想

  • 各関数をうまく分離できたので、責務がわかりやすくなりました。
  • 他のドメイン(Card,Commnet)などGlobalなものが出来たときはコピーして、他に使いやすい

他にやっておきたいこと

  • かなり荒々しく作ったため、更新処理中に別の更新処理が行われた時のバッティングした時の想定などはしていないため、そこは詰めていきたい。

終わり

最終的に useContext で管理をしましたが、これが状態管理のベストな方法とは思っておりません。ライブラリを使うのはもちろん、途中で作成した useHooks なども場面によっては非常に役に立ったりするものだと再実感しました。

規模が大きいとどうしてもライブラリで統一をしていきたいですが、場合によってはライブラリでは扱いにくいものもでるかもしれません。

そんな時は今回のようにカスタムできるようになればどんな場面でも対処がしやすくなれるんじゃないかと少し自信を持てました。

Discussion