DEV Community

Amir Reza Dalir for DiPhyx

Posted on

Eliminate 80% of Nuxt store boilerplate with a single createStore call

Every Nuxt app has the same problem.

For every API resource — users, posts, products, orders — the same code gets written:

  1. Define a TypeScript interface
  2. Create reactive state (ref, reactive)
  3. Create a loading ref
  4. Create an error ref
  5. Write a fetch function with try/catch/finally
  6. Repeat for create, update, delete
  7. Repeat for the next resource

Harlemify is an open-source Nuxt module by Diphyx that eliminates this repetition. It's built on top of Harlem, a powerful and extensible state management library for Vue 3 — so the reactive store layer is solid and battle-tested.

Define your data shape once, get everything else for free

Harlemify generates typed stores from Zod schemas. Here's what a full CRUD store looks like:

import { createStore, shape, ModelOneMode, ModelManyMode } from "@diphyx/harlemify/runtime";

const userShape = shape((factory) => {
    return {
        id: factory.number().meta({
            identifier: true,
        }),
        name: factory.string(),
        email: factory.email(),
    };
});

export const userStore = createStore({
    name: "users",
    model({ one, many }) {
        return {
            current: one(userShape),
            list: many(userShape),
        };
    },
    view({ from }) {
        return {
            user: from("current"),
            users: from("list"),
        };
    },
    action({ api }) {
        return {
            list: api.get(
                {
                    url: "/users",
                },
                { model: "list", mode: ModelManyMode.SET },
            ),
            get: api.get(
                {
                    url: "/users/:id",
                },
                { model: "current", mode: ModelOneMode.SET },
            ),
            create: api.post(
                {
                    url: "/users",
                },
                { model: "list", mode: ModelManyMode.ADD },
            ),
            delete: api.delete(
                {
                    url: "/users/:id",
                },
                { model: "list", mode: ModelManyMode.REMOVE },
            ),
        };
    },
});
Enter fullscreen mode Exit fullscreen mode

No separate type definitions. No manual loading/error refs. No try/catch boilerplate.

What comes out of this single definition

Typed modelscurrent holds a single user, list holds a collection. Both are fully typed from the Zod shape.

Reactive viewsuser and users are computed refs that update whenever the underlying model changes. Powered by Harlem's reactive store engine under the hood.

API actions with auto-commit — Each action knows which model to update and how (SET replaces, ADD appends, REMOVE deletes by identifier).

Automatic status tracking — Every action exposes loading, error, and status without writing a single ref.

Using it in components

<script setup>
const { execute, loading, error } = useStoreAction(userStore, "list");
const { data: users } = useStoreView(userStore, "users");

await execute();
</script>

<template>
    <p v-if="error">{{ error.message }}</p>
    <ul v-else-if="!loading">
        <li v-for="user in users" :key="user.id">{{ user.name }}</li>
    </ul>
</template>
Enter fullscreen mode Exit fullscreen mode

Three composables cover everything:

  • useStoreAction — execute actions, track loading/error/status
  • useStoreView — access reactive computed state
  • useStoreModel — mutate state directly (set, patch, add, remove, reset)

Beyond basic state management

Concurrency control

Every action can be configured with a concurrency strategy:

  • BLOCK — throw if the action is already running
  • SKIP — return the existing promise
  • CANCEL — abort the previous call, start a new one
  • ALLOW — run both in parallel

No more debouncing hacks or manual AbortController wiring.

Record collections

For grouped data (e.g., users by team), use record mode:

model({ many }) {
    return {
        byTeam: many(userShape, { kind: ModelManyKind.RECORD }),
    };
},
Enter fullscreen mode Exit fullscreen mode

This gives you { teamA: [...users], teamB: [...users] } with typed CRUD operations per key.

Handler actions

Not every action is an API call. Custom logic gets first-class support:

action({ handler }) {
    return {
        sortByName: handler(async ({ model }) => {
            const sorted = [...model.list].sort((a, b) => a.name.localeCompare(b.name));
            model.list = sorted;
        }),
    };
},
Enter fullscreen mode Exit fullscreen mode

Handlers have full access to models and views, with the same status tracking as API actions.

SSR with automatic hydration

Harlemify uses Harlem's SSR plugin (@harlem/plugin-ssr) under the hood. Server-rendered state is automatically hydrated on the client — no manual transfer or serialization needed.

How it compares

Manual / Pinia Harlemify
Schema Separate TypeScript interfaces Zod shape — types + validation
API calls Manual fetch + state updates Declarative with auto-commit
Loading/error Manual refs per action Automatic per action
Concurrency Not built-in Block, Skip, Cancel, Allow
Collections Manual array management Built-in modes (SET, ADD, PATCH, REMOVE)
SSR Plugin required Built-in via Harlem SSR
Lines per resource ~50-60 ~15-20

Harlemify is not a Pinia replacement. Pinia is great for general-purpose client state. Harlemify is built on top of Harlem and optimized for API-driven data — the CRUD resources that make up 80% of most apps.

Try it

npm install @diphyx/harlemify
Enter fullscreen mode Exit fullscreen mode
// nuxt.config.ts
export default defineNuxtConfig({
    modules: ["@diphyx/harlemify"],
});
Enter fullscreen mode Exit fullscreen mode

Feedback and contributions are welcome — star the repo if it's useful, or open an issue for anything that's missing.

Top comments (0)