โ† Back to DevBytes

State Management in Drizzle ORM: Patterns and Libraries

Introduction to State Management in Drizzle ORM

Drizzle ORM has rapidly become one of the most popular TypeScript-first ORM solutions for Node.js developers. Unlike traditional ORMs that hide SQL behind heavy abstractions, Drizzle embraces SQL while providing type safety and a fluent query builder. However, when building real-world applications, you quickly run into questions that go beyond simple CRUD operations: How do you track changes to entities? How do you manage transactions across multiple repositories? How do you keep your application state in sync with your database state?

State management in the context of Drizzle ORM refers to the patterns, techniques, and libraries used to handle the lifecycle of data as it moves between your application layer and your database. This includes everything from tracking dirty fields and optimistic locking to managing unit-of-work patterns and integrating with frontend state libraries. In this tutorial, we will explore these concepts in depth, with practical code examples you can apply immediately.

Why State Management Matters with Drizzle

Drizzle ORM is intentionally lightweight. It does not ship with built-in change tracking, identity maps, or unit-of-work patterns like Hibernate or Entity Framework do. This is a deliberate design choice that keeps Drizzle fast and predictable, but it means you are responsible for managing state yourself. Without a clear strategy, you risk several problems:

By adopting proper state management patterns, you can avoid these pitfalls while still enjoying Drizzle's simplicity and performance.

Core Concepts

Entity State vs. Database State

The first distinction to understand is between entity state (the in-memory representation of your data in your application) and database state (the persisted representation). In Drizzle, when you fetch a row, you get a plain JavaScript object. There is no proxy or tracker attached to it. If you modify that object and want to persist the change, you must explicitly issue an update query.

Transactions as State Boundaries

A transaction defines a boundary within which a group of operations must succeed or fail together. Drizzle provides transaction support, and using it correctly is the foundation of any state management strategy. Think of a transaction as a temporary state sandbox: changes are visible only within the transaction until you commit.

Setting Up Drizzle

Before diving into patterns, let us set up a basic Drizzle project. We will use PostgreSQL as our database, but the concepts apply to MySQL and SQLite as well.

npm install drizzle-orm pg
npm install -D drizzle-kit @types/pg

Define your schema in a central file:

// schema.ts
import { pgTable, serial, text, integer, timestamp, varchar } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: varchar('email', { length: 255 }).notNull().unique(),
  name: varchar('name', { length: 255 }),
  version: integer('version').notNull().default(1),
  createdAt: timestamp('created_at').defaultNow(),
  updatedAt: timestamp('updated_at').defaultNow(),
});

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  userId: integer('user_id').notNull().references(() => users.id),
  title: text('title').notNull(),
  content: text('content'),
  published: integer('published', { mode: 'boolean' }).default(false),
  createdAt: timestamp('created_at').defaultNow(),
});

Now create your database connection and Drizzle instance:

// db.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL!,
});

export const db = drizzle(pool, { schema });

Pattern 1: Explicit Change Tracking

Since Drizzle does not track changes automatically, the simplest pattern is to track them explicitly in your service layer. You load an entity, compare it against incoming data, and issue an update only for fields that changed. This keeps your queries efficient and avoids accidentally overwriting fields the client did not intend to modify.

// services/userService.ts
import { db } from '../db';
import { users } from '../schema';
import { eq } from 'drizzle-orm';

export async function updateUserProfile(userId: number, input: Partial<{
  email: string;
  name: string;
}>) {
  // Load the current state
  const [existing] = await db.select().from(users).where(eq(users.id, userId));
  if (!existing) {
    throw new Error('User not found');
  }

  // Build a patch object containing only changed fields
  const patch: Record<string, unknown> = {};
  if (input.email !== undefined && input.email !== existing.email) {
    patch.email = input.email;
  }
  if (input.name !== undefined && input.name !== existing.name) {
    patch.name = input.name;
  }

  if (Object.keys(patch).length === 0) {
    return existing; // Nothing to update
  }

  patch.updatedAt = new Date();

  const [updated] = await db
    .update(users)
    .set(patch)
    .where(eq(users.id, userId))
    .returning();

  return updated;
}

This pattern is straightforward and works well for small to medium applications. The downside is that it becomes repetitive when you have many entities. You can extract the comparison logic into a helper function:

// utils/diff.ts
export function diff<T extends Record<string, unknown>>(
  current: T,
  incoming: Partial<T>
): Partial<T> {
  const changes: Partial<T> = {};
  for (const key of Object.keys(incoming) as (keyof T)[]) {
    if (incoming[key] !== current[key]) {
      changes[key] = incoming[key];
    }
  }
  return changes;
}

Pattern 2: Optimistic Locking with Version Columns

When multiple users or processes can modify the same row, you need a strategy to prevent lost updates. Optimistic locking is a popular approach: each row has a version number, and every update increments it. When you issue an update, you include the version you originally read. If the version in the database no longer matches, the update affects zero rows, and you know someone else changed the data first.

Our schema already includes a version column on the users table. Here is how to use it:

// services/optimisticUserService.ts
import { db } from '../db';
import { users } from '../schema';
import { eq, and, sql } from 'drizzle-orm';

export async function updateUserOptimistic(
  userId: number,
  expectedVersion: number,
  input: { email?: string; name?: string }
) {
  const [updated] = await db
    .update(users)
    .set({
      ...input,
      version: sql`${users.version} + 1`,
      updatedAt: new Date(),
    })
    .where(and(eq(users.id, userId), eq(users.version, expectedVersion)))
    .returning();

  if (!updated) {
    throw new Error(
      'Optimistic lock failed: the record was modified by another process.'
    );
  }

  return updated;
}

The caller is responsible for passing the version they read. In a typical REST API, the client sends the version as part of the request body or as an If-Match header. If the update fails, the client can re-fetch the data, merge changes, and retry.

Pattern 3: The Unit of Work Pattern

The Unit of Work pattern tracks all changes made during a business transaction and commits them together. While Drizzle does not provide this out of the box, you can build a lightweight implementation using its transaction API.

// unitOfWork.ts
import { db } from './db';
import { PgTransaction } from 'drizzle-orm/node-postgres';
import * as schema from './schema';

type Tx = PgTransaction<typeof schema>;

interface PendingOperation {
  execute: (tx: Tx) => Promise<void>;
}

export class UnitOfWork {
  private operations: PendingOperation[] = [];

  add(operation: (tx: Tx) => Promise<void>) {
    this.operations.push({ execute: operation });
  }

  async commit() {
    await db.transaction(async (tx) => {
      for (const op of this.operations) {
        await op.execute(tx);
      }
    });
    this.operations = [];
  }

  get count() {
    return this.operations.length;
  }
}

Here is an example of using the Unit of Work to create a user and their first post atomically:

// services/registrationService.ts
import { UnitOfWork } from '../unitOfWork';
import { users, posts } from '../schema';

export async function registerUserWithWelcomePost(email: string, name: string) {
  const uow = new UnitOfWork();

  let newUserId: number | undefined;

  uow.add(async (tx) => {
    const [user] = await tx
      .insert(users)
      .values({ email, name })
      .returning({ id: users.id });
    newUserId = user.id;
  });

  uow.add(async (tx) => {
    if (!newUserId) throw new Error('User ID not available');
    await tx.insert(posts).values({
      userId: newUserId,
      title: 'Welcome to our platform!',
      content: `Hello ${name}, glad to have you here.`,
      published: true,
    });
  });

  await uow.commit();
  return { userId: newUserId };
}

The key benefit here is that if the post insertion fails, the user insertion is rolled back automatically because both operations share the same transaction.

Pattern 4: Repository Pattern with Injected Transactions

To make your data access layer testable and flexible, you can use the repository pattern where each repository accepts an optional transaction client. This allows the same repository methods to run either standalone or within a transaction.

// repositories/userRepository.ts
import { db } from '../db';
import { users, User } from '../schema';
import { eq } from 'drizzle-orm';

type QueryClient = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];

export class UserRepository {
  constructor(private client: QueryClient = db) {}

  async findById(id: number) {
    const [row] = await this.client
      .select()
      .from(users)
      .where(eq(users.id, id));
    return row;
  }

  async create(data: { email: string; name?: string }) {
    const [row] = await this.client
      .insert(users)
      .values(data)
      .returning();
    return row;
  }

  async updateEmail(id: number, email: string) {
    const [row] = await this.client
      .update(users)
      .set({ email, updatedAt: new Date() })
      .where(eq(users.id, id))
      .returning();
    return row;
  }
}

Now you can compose repositories within a transaction:

// services/accountService.ts
import { db } from '../db';
import { UserRepository } from '../repositories/userRepository';
import { PostRepository } from '../repositories/postRepository';

export async function transferOwnership(fromUserId: number, toUserId: number) {
  return db.transaction(async (tx) => {
    const userRepo = new UserRepository(tx);
    const postRepo = new PostRepository(tx);

    const fromUser = await userRepo.findById(fromUserId);
    const toUser = await userRepo.findById(toUserId);
    if (!fromUser || !toUser) throw new Error('User not found');

    await postRepo.reassignAll(fromUserId, toUserId);
    await userRepo.updateEmail(fromUserId, `archived+${fromUser.email}`);

    return { fromUser, toUser };
  });
}

Pattern 5: Event-Driven State Synchronization

In many applications, database changes need to trigger side effects: sending emails, updating search indexes, or publishing events to a message queue. A clean approach is to emit domain events from your service layer and handle them separately. This keeps your state management logic decoupled from infrastructure concerns.

// events/eventBus.ts
type EventHandler<T> = (payload: T) => Promise<void>;

export class EventBus {
  private handlers = new Map<string, EventHandler<unknown>[]>();

  on<T>(event: string, handler: EventHandler<T>) {
    const list = this.handlers.get(event) || [];
    list.push(handler as EventHandler<unknown>);
    this.handlers.set(event, list);
  }

  async emit<T>(event: string, payload: T) {
    const list = this.handlers.get(event) || [];
    for (const handler of list) {
      await handler(payload);
    }
  }
}

export const eventBus = new EventBus();

Use the event bus in your services:

// services/postService.ts
import { db } from '../db';
import { posts } from '../schema';
import { eventBus } from '../events/eventBus';

export async function publishPost(postId: number) {
  const [published] = await db
    .update(posts)
    .set({ published: true })
    .where(eq(posts.id, postId))
    .returning();

  if (published) {
    await eventBus.emit('post.published', {
      postId: published.id,
      userId: published.userId,
      title: published.title,
    });
  }

  return published;
}

Register handlers elsewhere in your application startup:

// events/handlers.ts
import { eventBus } from './eventBus';

eventBus.on('post.published', async (payload: any) => {
  console.log(`Sending notification for post ${payload.postId}`);
  // Send email, push notification, update search index, etc.
});

Libraries That Complement Drizzle for State Management

Zod for Input Validation

State management starts before data reaches your database. Validating input ensures that only well-formed data enters your system. Zod pairs naturally with Drizzle because both are TypeScript-first. You can even generate Zod schemas directly from your Drizzle schema using drizzle-zod.

npm install zod drizzle-zod
// validators/userValidator.ts
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
import { users } from '../schema';

export const insertUserSchema = createInsertSchema(users, {
  email: (schema) => schema.email.email(),
});

export const selectUserSchema = createSelectSchema(users);

// Usage
const parsed = insertUserSchema.parse({
  email: 'test@example.com',
  name: 'Test User',
});

Effect for Structured State and Error Handling

Effect is a functional programming library that provides a powerful way to manage state, errors, and asynchronous operations. You can wrap Drizzle queries in Effect workflows to get compositional error handling and retry logic.

npm install effect
// effects/userEffect.ts
import { Effect, pipe } from 'effect';
import { db } from '../db';
import { users } from '../schema';
import { eq } from 'drizzle-orm';

export const getUserById = (id: number) =>
  Effect.tryPromise({
    try: async () => {
      const [row] = await db.select().from(users).where(eq(users.id, id));
      if (!row) throw new Error('User not found');
      return row;
    },
    catch: (error) => new Error(`Database error: ${error}`),
  });

// Compose with retry
export const getUserWithRetry = (id: number) =>
  pipe(
    getUserById(id),
    Effect.retry({ times: 3 })
  );

Redis for Caching and Distributed State

When your application grows, you may need to cache database reads to reduce load. Redis is the standard choice. The key is to invalidate your cache whenever the underlying data changes, which ties directly into your state management strategy.

npm install ioredis
// cache/userCache.ts
import Redis from 'ioredis';
import { db } from '../db';
import { users } from '../schema';
import { eq } from 'drizzle-orm';

const redis = new Redis(process.env.REDIS_URL!);

export async function getCachedUser(id: number) {
  const cacheKey = `user:${id}`;
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  const [row] = await db.select().from(users).where(eq(users.id, id));
  if (row) {
    await redis.set(cacheKey, JSON.stringify(row), 'EX', 300);
  }
  return row;
}

export async function invalidateUserCache(id: number) {
  await redis.del(`user:${id}`);
}

Call invalidateUserCache whenever you update a user:

import { invalidateUserCache } from '../cache/userCache';

export async function updateUserEmail(id: number, email: string) {
  const [updated] = await db
    .update(users)
    .set({ email, updatedAt: new Date() })
    .where(eq(users.id, id))
    .returning();

  if (updated) {
    await invalidateUserCache(id);
  }

  return updated;
}

Drizzle Kit for Migrations as State Evolution

Database schema itself is a form of state that evolves over time. Drizzle Kit handles migrations, allowing you to version-control your schema changes. Always generate migrations for schema changes rather than applying them manually:

npx drizzle-kit generate
npx drizzle-kit migrate

Best Practices

Putting It All Together

Let us look at a complete example that combines several patterns: input validation, a repository with transaction support, optimistic locking, and cache invalidation.

// services/profileService.ts
import { db } from '../db';
import { users } from '../schema';
import { eq, and, sql } from 'drizzle-orm';
import { invalidateUserCache } from '../cache/userCache';
import { insertUserSchema } from '../validators/userValidator';

export async function updateProfile(
  userId: number,
  expectedVersion: number,
  rawInput: unknown
) {
  // 1. Validate input
  const input = insertUserSchema.partial().parse(rawInput);

  // 2. Perform optimistic update within a transaction
  const result = await db.transaction(async (tx) => {
    const [updated] = await tx
      .update(users)
      .set({
        ...input,
        version: sql`${users.version} + 1`,
        updatedAt: new Date(),
      })
      .where(and(eq(users.id, userId), eq(users.version, expectedVersion)))
      .returning();

    if (!updated) {
      throw new Error('Optimistic lock conflict');
    }

    return updated;
  });

  // 3. Invalidate cache after successful commit
  await invalidateUserCache(userId);

  return result;
}

This single function demonstrates a robust state management flow: validation prevents bad data from entering the system, the transaction ensures atomicity, optimistic locking prevents lost updates, and cache invalidation keeps downstream consumers in sync.

Conclusion

State management with Drizzle ORM is less about finding a single magic library and more about combining the right patterns for your application's complexity. For simple CRUD apps, explicit change tracking and basic transactions are sufficient. As your application grows, adopting the repository pattern, unit of work, optimistic locking, and event-driven synchronization will keep your code maintainable and your data consistent. Complement these patterns with libraries like Zod for validation, Effect for compositional error handling, and Redis for caching, and you will have a robust, type-safe data layer that scales with your needs. Drizzle's philosophy of staying close to SQL means you always have full visibility into what is happening, and with the patterns covered in this tutorial, you can build state management on top of that foundation with confidence.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles