#Reactivity race condition

19 messages · Page 1 of 1 (latest)

oblique hatch
#

Hi I have a specific scenario I'm struggling with that I could use suggestions on.

I have a Game Editor which queries the game based on a slug e.g. games/metroid-prime but the game editor also includes the ability to change the slug. If I change the slug and submit the update the page immediately errors because the query fetching the game data for the editor immediately reacts and game becomes undefined (because the slug changed so it no longer finds it). I tried redirecting to the new slug when the slug changes, but the app errors before that can happen since convex reacts instantly.

honest valleyBOT
#

Hi @oblique hatch. I'm kapa.ai, your friendly support bot. I will try to answer your question by searching through the knowledge base. :wave:

#

You’re running into exactly the kind of “overreactivity” issue Convex talks about: the query is correctly reacting to the slug change, but that’s not what you want for UX. You have a few options, all based on patterns in the docs and Stack posts.

1. Buffer / stabilize the query result

Use a custom hook that keeps returning the last non‑undefined value instead of flipping to undefined immediately when arguments change. This avoids the brief “no game found” state while you handle navigation.

The pattern is described as useStableQuery:[Overreacting impact]

// hooks/useStableQuery.ts
import { useRef } from "react";
import { useQuery } from "convex/react"; // or "../convex/_generated/react"

export const useStableQuery = ((name, ...args) => {
  const result = useQuery(name, ...args);

  const stored = useRef(result);

  if (result !== undefined) {
    stored.current = result;
  }

  return stored.current;
}) as typeof useQuery;

Then in your editor:

const game = useStableQuery(api.games.getBySlug, { slug });

// game is undefined only on *first* load,
// then stays at the last valid game object while the new slug loads.

Now when you submit the slug change:

  1. Run the mutation that updates the slug.
  2. useStableQuery keeps returning the old game object while the new query runs.
  3. You immediately navigate/redirect to the new route.
  4. On the new route, you mount a fresh query for the new slug.

This is exactly the “less reactive” behavior the article suggests for filter/param‑driven UIs to avoid jarring loading/error flashes.[Overreacting recap]

#

If you want a ready‑made version, the same pattern exists in convex-helpers as “enhanced useQuery” / useStableQuery.[RN enhanced hook]

2. Delay / “skip” the query during the transition

If there’s a moment where neither the old nor the new slug should be queried (e.g. while the form is submitting and before you navigate), you can temporarily “skip” the query by passing "skip" for args:[React fetching]

const shouldQuery = !!slug && !isSubmitting;

const game = useQuery(
  api.games.getBySlug,
  shouldQuery ? { slug } : "skip"
);

While "skip" is passed, Convex doesn’t talk to the backend and just returns undefined, letting you render a neutral “saving…” state instead of an error.

3. Keep a local copy of the doc and update optimistically

A lighter‑weight alternative is to keep a local game state that you seed from the query once, and update that state when the slug mutation succeeds, while the underlying query is in flux. This is similar to the “keep your own list in React state and only refresh when you want” pattern suggested for batch / Tinder‑like UIs.[Batch UX pattern]


If you want the minimal change that directly fixes “page errors before I can redirect”, I’d start with useStableQuery: it’s specifically meant to prevent intermediate undefined / error states when query arguments change, exactly like your slug.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@oblique hatch Kapa.ai is still learning and improving, please let me know how I did by reacting below.

oblique hatch
#

@honest valley useStableQuery doesn't seem to work? I think because it instantly reacts and re-fetches so it gets another "valid" result but the result is undefined?

honest valleyBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

You’re right that useStableQuery still re-runs the underlying useQuery immediately when its args change, and if that new query result is undefined it will leave the ref unchanged. That pattern only works if “loading” shows up as undefined and “no result” shows up as something else. In your case, changing the slug can make the query synchronously return undefined as a valid result (game not found), so useStableQuery will happily “stabilize” on undefined.

Convex’s own docs make a key guarantee: undefined is only used as the loading state for useQuery; it is not a valid query return type.[UI patterns] The useStableQuery pattern hinges on that: it treats undefined as “still loading → don’t update the ref”.[Impacting reacting]

So for this to work in your slug editor:

  1. Make sure your Convex query for “game by slug” never intentionally returns undefined. For example, have it return null or throw an error if not found.
  2. Use useStableQuery exactly like in the article so that only the initial load can produce undefined, and later “no game found” is represented as null (or another sentinel), which will be treated as a valid result and stored in the ref.

Server:

// convex/games.ts
import { query } from "./_generated/server";

export const getBySlug = query({
  args: { slug: v.string() },
  handler: async ({ db }, { slug }) => {
    const game = await db
      .query("games")
      .withIndex("by_slug", q => q.eq("slug", slug))
      .unique();

    // IMPORTANT: don't return `undefined` here on "not found"
    return game ?? null;
  },
});

Client hook (unchanged from docs):

#
import { useRef } from "react";
import { useQuery } from "../convex/_generated/react";

export const useStableQuery = ((name, ...args) => {
  const result = useQuery(name, ...args);
  const stored = useRef(result);

  if (result !== undefined) {
    stored.current = result;
  }

  return stored.current;
}) as typeof useQuery;

Now the behavior is:

  • First mount: result === undefinedstored.current stays undefined → you show an initial loading state.
  • After load: result is either a game object or nullstored.current becomes that value.
  • When slug changes:
    • While the new slug is loading: result === undefined again, but stored.current still holds the old game object (or null), so your editor doesn’t blow up while you redirect.
    • When the new slug finishes resolving: stored.current updates to the new game (or null).

If you really do need “no game” to be represented as undefined in your component logic, you’ll need a different sentinel internally in the query (or wrap the query result in a shape like { found: boolean, game: Game | null }) so that useStableQuery can still use undefined purely as “loading”.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@oblique hatch Kapa.ai is still learning and improving, please let me know how I did by reacting below.

oblique hatch
#

@honest valley doesn't the db query you just showed already return null instead of undefined?

honest valleyBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

You’re correct to question that.

From the docs:

  • useQuery uses undefined only as the loading state; undefined is not a valid Convex value.[UI patterns]
  • On the server, a query can return undefined, but when that happens it is translated to null on the client.[Query constructor]

For your example:

const game = await ctx.db
  .query("games")
  .withIndex("by_slug", q => q.eq("slug", slug))
  .unique();

unique() returns either a document or null if no document matches.[Reading results; Query.unique]
So game here is already null | Doc, never undefined.

That means:

  • The “no game found” case from this query is represented as null.
  • On the client, useQuery will return:
    • undefined while loading (argument change, first mount).
    • null if the query resolved to “no document”.

So the useStableQuery pattern does work with this query shape: it will ignore only the undefined (loading) state and happily store null (not found) or a document as the last stable value.[Impacting reacting]

If you were seeing “valid undefined” in the component, that would almost certainly be useQuery’s loading state, not a value coming from db.unique().

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:

#

@oblique hatch Kapa.ai is still learning and improving, please let me know how I did by reacting below.

oblique hatch
#

@honest valley I see whats causing it, the query throws an error if its not found rather than returning null - what is the best practice for dealing with 404s? should I return null from convex and 404 in the client or throw a not found error?

honest valleyBOT
#

Let me check the knowledge base for you again. One moment! :books:

#

For Convex, both patterns are valid; which you choose is mostly about UX and observability.

Relevant pieces from the docs:

  • Queries/mutations can signal expected failures either by returning different values or by throwing ConvexError.[App errors; Error handling overview]
  • On the client, thrown errors from queries are best handled with error boundaries; for mutations you typically use try/catch.[Error handling overview; App errors]
  • For RLS/authorization, Convex discusses the trade‑off between throwing vs pretending it doesn’t exist (silent filtering / “not found”), and notes that surfacing as an error can be better when it’s about catching bugs.[RLS errors vs hiding]

Given that, here are the two idiomatic options:

1. Return null / a discriminated result

Best when “not found” is a normal, non‑exceptional case (e.g. user typed a slug that might not exist yet).

Server:

export const gameBySlug = query({
  args: { slug: v.string() },
  handler: async (ctx, { slug }) => {
    const game = await ctx.db
      .query("games")
      .withIndex("by_slug", q => q.eq("slug", slug))
      .unique(); // null if none[[Reading results](<https://docs.convex.dev/database/reading-data#retrieving-results>)]

    return game; // null | Doc
  },
});

Client:

const game = useQuery(api.games.gameBySlug, { slug });
#

if (game === undefined) return <Spinner />; // loading[UI patterns]
if (game === null) return <NotFoundPage />; // 404‑like UI
return <GameEditor game={game} />;


You can also use a more explicit result type, e.g. `{ status: "ok"; game } | { status: "not_found" }`.[[App errors](<https://docs.convex.dev/functions/error-handling/application-errors>)]

### 2. Throw a `ConvexError` for “not found”

**Best when “not found” usually indicates a bug / misuse**, or you want to centralize handling via error boundaries.

Server:

```ts
import { ConvexError } from "convex/values";

export const gameBySlug = query({
  args: { slug: v.string() },
  handler: async (ctx, { slug }) => {
    const game = await ctx.db
      .query("games")
      .withIndex("by_slug", q => q.eq("slug", slug))
      .unique();

    if (!game) {
      throw new ConvexError({ code: 404, message: "Game not found" });
    }

    return game;
  },
});

Client:

Recommendation for your slug editor

Because “game not found for this slug” is a normal state (especially during a slug change / navigation), it’s usually smoother to:

#
  1. Have the query return null when no game exists.
  2. Treat undefined as loading, null as “404” in the UI.
  3. Use useStableQuery or similar to avoid transient “not found” while you redirect; that works well when “not found” is null and undefined is reserved purely for loading.[UI patterns; Overreacting article]

Throwing a ConvexError for 404 is reasonable if you want to treat it like an error and catch it in an error boundary, but for your specific race‑y slug‑change flow, returning null and handling it as state is typically less brittle.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon: