The Architect's Symphony: Composing a Data Layer Beyond React Query and SWR

A

Alex Aslam

Guest
You stand atop the skyscraper of your application, the city of components buzzing below. For years, you’ve relied on trusted stewards—React Query, SWR—to manage the lifeblood of your metropolis: data. They are brilliant. They handle the basics of fetching, caching, and synchronizing state with an elegance we once could only dream of.

But you’re a senior engineer. You’ve felt the subtle cracks. The useQuery calls sprinkled like confetti across hundreds of components. The awkward dance of invalidating mutations that touch multiple, disparate queries. The silent, mounting complexity that turns a simple feature request into a archeological dig through layers of cached keys.

It’s time to journey beyond the off-the-shelf solution. It’s time to stop merely using a data layer and start composing one. To move from tenant to architect.

This is the art of building a robust, intentional async data layer.

The Gallery of Shortcomings: Why the Best Tools Aren't Enough​


Our beloved libraries are masterful impressionist paintings—beautiful from a distance, but a bit blurry on the intricate details. For most apps, they are perfect. But for complex, large-scale applications, we start to see the limitations:

  1. The Colocation Conundrum: The "hook-per-component" model brilliantly colocates data with its UI. But it also scatters the knowledge of what data exists and how to change it across the entire application. There is no single source of truth for your domain logic.
  2. The Cache Key Carnival: We tie our logic to fragile string-based keys (['posts', 123]). A mutation in one part of the app must know the exact incantation of keys to invalidate in another. It’s a covert coupling that becomes a maintenance nightmare.
  3. The Black Box: We lose fine-grained control. How is the cache actually stored? How are requests deduplicated? How would we implement a custom caching strategy, like a write-through cache to IndexedDB? We’re often locked into the library’s choices.
  4. The Bundle Beast: We bring in a powerful, generic library to solve our specific problems. It contains code for features we may never use, all paid for on the client’s dime.

Your journey begins with the realization: We don’t need a smarter cache; we need a smarter interface to our data.

The Architect's Blueprint: Designing the Data Layer​


Imagine your data layer not as a cache, but as a central, stateful orchestra conductor. It doesn’t just remember who played what; it knows the entire score, the musicians, and how to direct them in perfect harmony.

Our composition will be built on four core movements:

1. The Unified API Layer: The Instrument Library

First, we standardize how we talk to the outside world. No more arbitrary fetch calls buried in hooks.


Code:
// api/todoApi.ts
// A pure, framework-agnostic instrument for playing the "Todo" score.
export const todoApi = {
  getTodos: (): Promise<Todo[]> => fetch('/api/todos').then(r => r.json()),
  getTodo: (id: string): Promise<Todo> => fetch(`/api/todos/${id}`).then(r => r.json()),
  updateTodo: (id: string, patch: Partial<Todo>): Promise<Todo> =>
    fetch(`/api/todos/${id}`, {
      method: 'PATCH',
      body: JSON.stringify(patch),
    }).then(r => r.json()),
  // ... more instruments
};

This is our foundation. It has no state, no framework dependencies. It’s just a collection of perfectly crafted instruments.

2. The Data Store: The Conductor's Score

This is the heart of our symphony. We’ll create a central store (using Zustand, Redux, or even just a context + reducer) that holds our application's domain state—the data we’ve fetched.


Code:
// stores/useTodoStore.ts
import { create } from 'zustand';
import { todoApi } from '../api/todoApi';

interface TodoStore {
  todos: Record<string, Todo>; // Normalized cache: { '1': {id: '1', ...}, '2': ... }
  loading: boolean;
  errors: Record<string, string>;

  // The conductor's actions
  actions: {
    fetchTodo: (id: string) => Promise<void>;
    fetchTodos: () => Promise<void>;
    updateTodo: (id: string, patch: Partial<Todo>) => Promise<void>;
  };
}

export const useTodoStore = create<TodoStore>((set, get) => ({
  todos: {},
  loading: false,
  errors: {},

  actions: {
    fetchTodo: async (id: string) => {
      try {
        const todo = await todoApi.getTodo(id);
        set((state) => ({
          todos: { ...state.todos, [todo.id]: todo },
        }));
      } catch (error) {
        set((state) => ({
          errors: { ...state.errors, [id]: error.message },
        }));
      }
    },
    // ... other actions implemented with a similar pattern
  },
}));

Notice the normalization. We store items in a dictionary keyed by ID. This makes updates instantaneous and eliminates duplication.

3. The Custom Hook: The First-Chair Violinist

Now, we create our own elegant hooks that interface with the store. This is where we inject our own optimizations and logic.


Code:
// hooks/useTodos.ts
import { useTodoStore } from '../stores/useTodoStore';
import { useEffect } from 'react';

export const useTodos = () => {
  const { todos, loading, errors, actions } = useTodoStore();

  // Automatically fetch on mount? Maybe, maybe not. Your choice.
  useEffect(() => {
    actions.fetchTodos();
  }, []);

  // Derive data as needed
  const completedTodos = Object.values(todos).filter(todo => todo.completed);

  return {
    todos: Object.values(todos), // Return an array for the UI
    completedTodos,
    loading,
    errors,
    updateTodo: actions.updateTodo, // Expose the action
  };
};

This hook is now our single, controlled interface for anything related to Todos. The component no longer cares about cache keys or libraries.

4. The Advanced Techniques: The Master's Composition

Here is where we paint our masterpiece. Our custom layer gives us a single place to implement powerful patterns:

  • Request Deduplication: Wrap your API calls in a simple Promise caching function to ensure simultaneous calls for the same data are deduplicated.
  • Optimistic Updates: Within updateTodo, immediately update the store before the request resolves, then handle rollback on error. The logic is all in one, coherent place.
  • Persistence: Easily layer in a write-through cache to localStorage or IndexedDB by modifying the store.
  • Offline Support: The store becomes the single source of truth you can hydrate from a persistent layer when the app loads.

The Finished Symphony: A New Harmony​


In a component, the chaos of scattered hooks is replaced with serene intent:


Code:
// Before: Scattered, coupled, and fragile
// const { data: todos } = useQuery(['todos'], fetchTodos);
// const { mutate: updateTodo } = useMutation(updateTodoApi, {
//   onSuccess: () => {
//     queryClient.invalidateQueries(['todos']);
//     queryClient.invalidateQueries(['todo', id]);
//   },
// });

// After: Intentional, decoupled, and robust
const { todos, loading, updateTodo } = useTodos();

const handleComplete = (id: string) => {
  updateTodo(id, { completed: true });
};

The component is freed. It knows nothing of cache keys, invalidation strategies, or underlying libraries. It simply declares its intent. The data layer, our conductor, handles the rest with precision.

The Curator's Note​


This journey isn’t about abandoning React Query or SWR. They are magnificent tools. This is about the art of abstraction and intentionality. It’s about recognizing when your application’s needs have outgrown a generic solution and demanding a system that is uniquely tailored to your domain.

You are no longer just a user of tools. You are a composer, an architect. You have looked beyond the cache and built not just a feature, but a foundation—a robust, scalable, and masterfully conducted async data layer.

Now go, and build your symphony.

Continue reading...
 


Join đť•‹đť•„đť•‹ on Telegram
Channel PREVIEW:
Back
Top