Skip to content

Save Tasks ​

Keep the tasks from Complete, Star, and Delete Tasks between launches. Zustand handles persistence; a file adapter connects it to the GTKX application's data directory.

A storage backend ​

Store task content in the application's data directory. Preferences use GSettings, introduced in Add Preferences.

Zustand's persist middleware accepts a custom storage adapter. GTKX runs on Node.js, so the adapter can use filesystem APIs directly.

Create src/store/storage.ts:

ts
import { fileSetContents, getUserDataDir } from "@gtkx/gi/glib";
import type { StateStorage } from "zustand/middleware";
import { mkdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { applicationId } from "virtual:gtkx-config";

const directory = join(getUserDataDir(), applicationId);
const file = join(directory, "tasks.json");

export const fileStorage: StateStorage = {
    getItem: () => {
        try {
            return readFileSync(file, "utf8");
        } catch (error) {
            if (error instanceof Error && "code" in error && error.code === "ENOENT") {
                return null;
            }

            throw error;
        }
    },
    setItem: (_name, value) => {
        mkdirSync(directory, { recursive: true });
        fileSetContents(file, Buffer.from(value));
    },
    removeItem: () => {
        rmSync(file, { force: true });
    },
};

GLib supplies the desktop data directory and replaces the saved file atomically. Only a missing file starts a fresh store; other read errors and write errors propagate to the application.

Turning it on ​

Wrap the store creator from Add Tasks in persist. Nothing inside it changes.

The import line in src/store/index.ts:

diff
@@ -1,0 +2,2 @@
+import { createJSONStorage, persist } from "zustand/middleware";
+import { fileStorage } from "./storage.js";

Then src/store/index.ts, with the action bodies left alone:

diff
@@ -18,33 +18,57 @@
-export const useStore = create<Store>()((set) => ({
-    tasks: seedTasks,
-    addTask: (listId, title) => {
-        const trimmed = title.trim();
-        if (trimmed === "") return null;
-        const id = crypto.randomUUID();
-        set((state) => ({
-            tasks: [
-                ...state.tasks,
-                {
-                    id,
-                    listId,
-                    title: trimmed,
-                    notes: "",
-                    done: false,
-                    important: false,
-                    deleted: false,
-                    due: null,
-                    position: state.tasks.length,
-                    createdAt: new Date().toISOString(),
-                    completedAt: null,
-                },
-            ],
-        }));
-        return id;
-    },
-    setDone: (id, done) =>
-        set((state) => ({
-            tasks: patch(state.tasks, id, { done, completedAt: done ? new Date().toISOString() : null }),
-        })),
-    setImportant: (id, important) => set((state) => ({ tasks: patch(state.tasks, id, { important }) })),
-    moveToTrash: (id) => set((state) => ({ tasks: patch(state.tasks, id, { deleted: true }) })),
-}));
+export type PersistedState = { tasks: Task[] };
+
+let hydrationError: unknown;
+
+export const useStore = create<Store>()(
+    persist(
+        (set) => ({
+            tasks: seedTasks,
+            addTask: (listId, title) => {
+                const trimmed = title.trim();
+                if (trimmed === "") return null;
+                const id = crypto.randomUUID();
+                set((state) => ({
+                    tasks: [
+                        ...state.tasks,
+                        {
+                            id,
+                            listId,
+                            title: trimmed,
+                            notes: "",
+                            done: false,
+                            important: false,
+                            deleted: false,
+                            due: null,
+                            position: state.tasks.length,
+                            createdAt: new Date().toISOString(),
+                            completedAt: null,
+                        },
+                    ],
+                }));
+                return id;
+            },
+            setDone: (id, done) =>
+                set((state) => ({
+                    tasks: patch(state.tasks, id, { done, completedAt: done ? new Date().toISOString() : null }),
+                })),
+            setImportant: (id, important) => set((state) => ({ tasks: patch(state.tasks, id, { important }) })),
+            moveToTrash: (id) => set((state) => ({ tasks: patch(state.tasks, id, { deleted: true }) })),
+        }),
+        {
+            name: "tasks",
+            version: 1,
+            storage: createJSONStorage(() => fileStorage),
+            partialize: (state): PersistedState => ({ tasks: state.tasks }),
+            migrate: () => {
+                throw new Error("Unsupported task data version");
+            },
+            onRehydrateStorage: () => (_state, error) => {
+                hydrationError = error;
+            },
+        },
+    ),
+);
+
+if (hydrationError !== undefined) {
+    throw hydrationError;
+}

partialize limits saved content to tasks. Later chapters add UI state that should reset when the app starts. The adapter uses one file, so it does not need the storage key passed as name.

Because this adapter is synchronous, saved tasks are loaded before the first render and each store update writes them to disk. On a fresh install, the list starts with seedTasks; an existing saved list, including an empty one, replaces those defaults.

The initialization check stops the app if loading or parsing fails, preserving the saved file. An unsupported format version also fails. When the app changes its saved format, replace that rejection with a migration; the middleware documentation covers that API.

Isolate test data ​

Before running the tests from Add Tasks, give them a temporary data directory so they cannot overwrite tasks in your development app. Replace vitest.config.ts with:

ts
import gtkx from "@gtkx/cli/vitest-plugin";
import { defineConfig } from "vitest/config";

export default defineConfig({
    plugins: [gtkx()],
    test: {
        include: ["tests/**/*.test.{ts,tsx}"],
        setupFiles: ["./tests/setup.ts"],
        bail: 1,
        env: {
            LANG: "C.UTF-8",
            LANGUAGE: "en",
            LC_ALL: "C.UTF-8",
        },
    },
});

Create tests/setup.ts:

ts
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll } from "vitest";

const dataHome = mkdtempSync(join(tmpdir(), "gtkx-tutorial-"));
process.env.XDG_DATA_HOME = dataHome;

afterAll(() => {
    rmSync(dataHome, { recursive: true, force: true });
});

Vitest runs this file before importing the app, so the storage backend reads the temporary path. The existing beforeEach in tests/tasks.test.tsx resets tasks between tests; those resets now also write to the temporary file. Keep npm test passing as you continue.

Run it ​

Save the files, add a task, and change its checkbox or star. Close the window, then start the app again:

bash
npm run dev

The task and its changes remain. Delete a task and restart once more to check that it stays deleted.

With the default data directory, you can inspect the saved tasks:

bash
jq .state.tasks ~/.local/share/com.gtkx.tutorial/tasks.json

If you set XDG_DATA_HOME, use that directory instead. Package the App covers running the same application in a Flatpak.

Next ​

Add Lists and a Sidebar splits the store into slices and gives tasks a list to belong to, reachable from a sidebar.