3

I have a Project type in my app. I need to be able to access it via two separate async function:

  • getProductBySlug
  • getProductById

At the root of the page I use the URL to derive the slug and query for it using a useGetProductBySlugQuery hook (with a queryKey of [products, productSlug]) but everywhere else in component hierarchy where I need to access a project I use a useProductByIdQuery hook (with a queryKey of [products, productId]), and there are a variety of other hooks that update projects which all use this queryKey too.

Although storing the same data in the cache at two locations isn't in itself a problem, this also means the same resource is stored in two different locations in the cache. This is problematic because:

  • Any hooks that use the [products, productId] will not be fired when [products, productSlug] is updated or invalidated.
  • Any hooks that use the [products, productSlug] will not be fired when [products, productId] is updated or invalidated.
  • Using the project returned form useGetProductBySlugQuery to supply the id to useGetProductByIdQuery results in an unnecessary second request to get the same data.

So far there appear to be two solutions to this problem:

  1. Manually update / invalidate both keys in each hook that uses either key. This involves a lot of duplicated logic, and is an obvious potential source of bugs.

  2. Create a special dependent query at the root that will be triggered only when the query using [projects, projectSlug] succeeds in returning a project. That way the only place there is a dependency on the slug query is at the root of the project, and the rest of the project and queries are completely oblivious to it, meaning there is no need to update the [projects, projectSlug] key at all.

Something like this:


const useCopyProjectToCacheQuery = (project: Project) => {
  const queryClient = useQueryClient()
  return useQuery({
    queryKey: projectKeyFactory.project(project?.id),
    queryFn: async () => {
      // Check the cache for data stored under the project's id
      const projectDataById = await queryClient.getQueryData(
        projectKeyFactory.project(project.id)
      )

      return isNil(projectDataById)
        ? // If the data isn't found, copy the data from the project's slug key
          await queryClient.getQueryData<ProjectWithRelations>(
            projectKeyFactory.project(project.id)
          )
        : // Otherwise get it from the server
          await findProjectByIdAction(project.id)
    },
    enabled: isNotNil(project),
  })
}

This will populate the [projects, projectId] key with the project from the [projects, projectSlug] key when it is first populated, then will run a normal query on subsequent calls.

The second option appears to be working fine (although I end up with a cache entry for the key [projects, null]created whileprojectis null), but this seems like a really clumsy way to solve the problem. My next through was to subscribe to the QueryCache and copy the data from each key to the other whenever one of the keys changes, however [the docs][1] forQueryCache.subscribe` state:

Out of scope mutations to the cache are not encouraged and will not fire subscription callbacks

So what is the correct way to deal with this situation?

1
  • Isn't it possible to use a single function - getProductByIdOrSlug? And you can have a query key like this - ['prodicts', { id: 1, slug: 'product-slug' }]. Commented Feb 9 at 2:35

2 Answers 2

1
+250

a) update id when fetching slug (using onSuccess)

Instead of relying on a separate dependent query (useCopyProjectToCacheQuery), you can synchronize the cache manually within onSuccess of useGetProductBySlugQuery. This ensures that when you fetch by slug, the data is also stored under the ID-based query key.

const useGetProductBySlugQuery = (slug: string) => {
    const queryClient = useQueryClient();

    return useQuery({
        queryKey: projectKeyFactory.projectSlug(slug),
        queryFn: () => findProjectBySlugAction(slug),
        onSuccess: (data) => {
          if (data?.id) {
            queryClient.setQueryData(projectKeyFactory.project(data.id), data);
          }
        },
    });
};
  • ensures products, productId is updated when products, productSlug is fetched
  • components querying productId will have fresh data without an extra fetch

b) update id when fetching slug (using single point of entry)

unify the fetching function to allow both slug and ID to resolve to the same key (ie. prevent duplicate entries in the cache entirely because under the hood slugs are translated to IDs)

const useGetProductQuery = (id?: string, slug?: string) => {
    const queryClient = useQueryClient();

    return useQuery({
        queryKey: id ? projectKeyFactory.project(id) : projectKeyFactory.projectSlug(slug),
        queryFn: async () => {
            if (id) return await findProjectByIdAction(id);
            if (slug) {
                const project = await findProjectBySlugAction(slug);
                if (project?.id) {
                    queryClient.setQueryData(projectKeyFactory.project(project.id), project);
                }
                return project;
            }
            throw new Error("Either id or slug must be provided");
        },
        enabled: Boolean(id || slug),
    });
};

c) bidirectional key synchronization (useEffect)

... if you really have to keep the dual keys as-is and want to dive into hell and memory is just money or you simply have too many existing hooks ...

const useSyncProjectCache = () => {
    const queryClient = useQueryClient();

    useEffect(() => {
        const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
            if (event?.type !== 'updated') return; // ignore fetch / remove

            const queryKey = event.query.queryKey;
            const updatedProject = event.query.state.data as Project | undefined;

            if (!updatedProject?.id || !updatedProject?.slug) return; // ignore early

            queryClient.batch(() => {
                const currentById = queryClient.getQueryData<Project>(['products', updatedProject.id]);
                const currentBySlug = queryClient.getQueryData<Project>(['products', updatedProject.slug]);

                // Sync Slug -> ID
                if (queryKey[1] === updatedProject.slug && !isEqual(currentById, updatedProject)) {
                    queryClient.setQueryData(['products', updatedProject.id], updatedProject);
                    queryClient.invalidateQueries(['products', updatedProject.id], { exact: true });
                }

                // Sync ID -> Slug
                if (queryKey[1] === updatedProject.id && !isEqual(currentBySlug, updatedProject)) {
                    queryClient.setQueryData(['products', updatedProject.slug], updatedProject);
                    queryClient.invalidateQueries(['products', updatedProject.slug], { exact: true });
                }
            });
        });

        return unsubscribe;
    }, [queryClient]);
};
Sign up to request clarification or add additional context in comments.

3 Comments

FWIW, with solution a. there is no mechanism to update the slug key, meaning if this hook is used at the root of a project, and a component somewhere in the hierarchy queries using project id, components using this query will not get fresh data. It only moves state from slug => id so it doesn't solve the problem. b) suffers from the same issue. Components using this with a slug will not get updated when a component uses it with an id. Again this doesn't solve the problem.
this is just exemplary code - obviously there are other functions required, however the main idea in a an b is to convert slug access to id access. The later and id-update is assumed to be the "proper" way. The goal should technically be to only cache one normalized entity and not sync at all.
Ok, but it's not solving the problem the question lays out. I don't think the problem is necessarily caching the data twice (unless it's huge). The problem is bidirectional sync. It's easy to have one query write to the cache of another. The problem is ensuring changes to that cache propagate back to the original query.
1

The main problem is that you’re storing the same product in two different places in your cache—one keyed by the slug and one by the id. When one changes, the other doesn’t, which can lead to unnecessary extra requests and inconsistent data.

The best way to solve this is to have a single, “source of truth” for each product in your cache. In most cases, that should be the product’s id since it’s unique. Here’s how I would do it:

  1. Use the slug only to look up the id. When you fetch a product using its slug, let that call return the product’s id (or the full product data). Then, use that id for everything else. Essentially, you’d only store and refer to products using their id in the cache.

  1. Update the id-based cache when you fetch by slug. If you need to fetch the product by its slug initially, update the cache for the id-based key as soon as you get the data. Here is a code example

    const { data: product } = useGetProductBySlugQuery(slug, {
        onSuccess: (product) => {
           // Save the product in the cache using its id as the key.
           queryClient.setQueryData(['products', product.id], product);
        }
    });
    

See documentation for queryClient.setQueryData

This way, whenever another part of your app uses useProductByIdQuery, it will find the data in the cache and won’t need to make another network request. Hope this helps!

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.