✨ EFLx ☁️

Back

Why does this matter?#

This article focuses on purely client-side applications. We aren’t discussing React Server Components (RSC) or Server Functions here. We are dealing with scenarios where data fetching and mutation logic happen entirely within the browser’s rendering lifecycle.

In reality, many developers are still manually managing states like data, isLoading, and error, utilizing useEffect to fetch data. I don’t need to lecture you on why that’s brittle; by using libraries like useSWR or others, we can implement these features more naturally while enjoying benefits like caching, global mutation, and request deduplication.

For the sake of convenience, I’ll use SWR for the examples below. These libraries use a “key” to manage the cache. If a “key” already has corresponding data, all useSWR hooks using that same “key” will share that data state. You can “mutate” a specific “key,” causing all subscribers to re-fetch and update based on their memoization strategies. The “key” can be an array or an object. This allows us to embed the parameters required for the request directly into the key, letting the fetcher extract them. This handy feature saves us from constantly rebuilding callbacks via useCallback.

The problem lies in inconsistency#

Based on the features above, we can identify several potential pitfalls:

  • Is the key strictly 1:1 with the fetcher? If not, chaos ensues. Global mutations might fail silently (e.g., you think you mutated the correct key, but a component somewhere else is bound to a slightly different key structure).
  • Is the key format standardized across the project? Without a standard, maintainability plummets.
  • Is it decoupled enough? If you write (key) => updateUserName({ id: key[0], name: key[1] }) directly as your fetcher, a simple change to the key structure or the backend API signature could trigger a massive refactoring headache.

Therefore, we need a unified component (or pattern) to manage the relationships between these keys, data fetching functions, mutation behaviors, and the targets of those mutations. It’s actually easier than it sounds. (Confident.jpg)

Registering Keys and Fetchers#

First, let’s establish a mental model:

  1. The source and type of the data will be called the endpoint. If you’ve written an SWR key like ['user', ...], then user is the endpoint.
  2. Let’s say you have a user endpoint. If you want to fetch a list of all users (perhaps with filters), you should separate that into a distinct endpoint like users, userList, or allUsers. Do not reuse the singular user endpoint. Furthermore, any given endpoint should map 1:1 to a specific fetcher function. Whether you split endpoints fundamentally depends on your backend implementation.
  3. A key often includes a scope, which refers to a specific constraint, such as a User ID.

Let the battle with the TypeScript type system begin!

Ideally, the frontend should be able to generate a key like this:

useSWR(userKey({ scope: userId, ... }), ...)
ts

In this case, userKey would be a function that accepts TParams and returns { endpoint: 'user' as const } & TParams.

To simplify this and enforce a standard construction method, we can write a quick factory function. Interestingly, we can inject a $inferParams (and also $inferKey!) property via type assertion. This allows us to access that ” TParams ” type later using typeof userKey.$inferParams. You’ll see why this is useful in a moment. I learn this from drizzle-orm. Thanks!

const createKey =
  <TParams extends Record<string, unknown> = { scope: string }>() =>
  <const T>(endpoint: T) => {
    const fn = (params: TParams) => ({
      endpoint,
      ...params,
    })

    return fn as ((params: TParams) => { endpoint: T } & TParams) & {
      $inferParams: TParams,
      $inferKey: { endpoint: T } & TParams
    }
  }
ts

When no generic parameter is provided, it defaults to requiring a scope in the key construction. Usage looks like this:

const userKey = createKey()('user')
ts

If an endpoint requires more parameters to fetch data, we define it like this:

const userKey = createKey<{
  scope: string
  organization: string
}>()('user')
ts

I recommend defining the key type simultaneously with the key creator. This gives us some useful union types for later use.

export type AppSWRKeys = typeof userKey.$inferKey
export type AppSWREndpoints = AppSWRKeys['endpoint']
ts

Following SWR best practices—and to abstract the choice of fetcher away from the UI components—let’s define a dedicated 👑 hook for the user. Here is where .$inferParams shines by simplifying our type definitions 🥰.

const useUser = (params: typeof userKey.$inferParams, config?: SWRConfiguration) =>
  useSWR(userKey(params), ({ scope: id }) => getUserById({ id }), config)
ts

Let’s compare fetching user data before and after this transformation:

// Before SWR
const [data, setData] = useState<...>()
const [error, setError] = useState<...>()
const [loading, setIsLoading] = useState<...>()
useEffect(() => {
  getUserById({ id: userId }).then(data => setData(data)).catch(...).finally(...)
}, [userId]) // ❌ Manually handling lifecycle via useEffect
ts
// Naive SWR usage
// ❌ Closure captures external 'userId', avoiding useCallback/dependency array check issues, but it's brittle.
const { data, error, isLoading } = useSWR(['user', userId], () => getUserById({ id: userId }))

// ❌ No unified constraints on key construction or how the fetcher receives args.
const { data, error, isLoading } = useSWR(['user', userId], ([_, id]) => getUserById({ id }))
ts
// The Refactored Way
// ✅ Auto-completion, consistent keys, no need to specify fetcher manually.
const { data, error, isLoading } = useUser({ scope: userId })
ts

How about that? Everything just became clearer.

  • If the logic for fetching user data changes, you only update the useUser definition.
  • You don’t need to dig through massive API docs to remember which endpoint to call.
  • You don’t need to think about how to structure the SWR key. Or rather, we moved that cognitive load upfront to when useUser was defined.
  • It looks clean and concise! ✨

Handling Mutation and Optimistic Updates#

Directly await-ing an operation function or POSTing to the backend inside your component is common behavior—but it’s bad practice. If you forget to mutate (revalidate), your UI state becomes stale. If the UI doesn’t update, your user might start poking their iPad screen aggressively, potentially generating more hardware revenue for Apple! Or perhaps they’ll choose the gentle approach: spamming the refresh button… effectively launching a DDoS attack on your API 🤔?

The best practice remains separation of concerns. Since we can’t expect frontend developers to remember to mutate every single time, let’s expose a trigger function from a hook that handles the mutation internally.

Obviously, we need some utility functions first. For example, a helper to globally mutate any useSWR keys that match a specific condition:

Usage:

await mutateGlobal({ key: ..., data: ... /* 'data' is optional, used for optimistic updates */ })
ts

Since we unified our mental model earlier, implementing the required mutation hook is now trivial.

This component is tightly coupled with business logic (backend API calls, function signatures, specific mutation conditions, etc.), thus we won’t abstract it further.

You can also add other data actions to this hook so the frontend can access them quickly.

Furthermore, optimistic updates have become an essential consideration in modern app developments. Remember to use the data property in the mutateGlobal matcher. Please note this relies on that the action succeeded with a returning. If a “real” or “immediate” optimistic update is needed, put the optimistic data in trigger function. Users will see the UI react instantly, faster than any shooting star 🌠 preventing them from making a wish on your loading spinner! You can also add onError handling or allow the frontend to pass optional options that get piped through to useSWRMutation.

Summary#

The key to everything lies in establishing a unified, consistent mental model. Isolate the messy logic into a separate layer and leverage the power of React hooks. Let me wish this helps you build safe, sustainable code before LLMs ruin your c…ode… bro that’s not funny.

Safe & Consistent SWR / TanStack Query
https://eflx.top/blog/safe-consistent-swr
Author EFL
Published at January 17, 2026