swr icon indicating copy to clipboard operation
swr copied to clipboard

Easily mutate resource in multiple keys

Open nicholaschiang opened this issue 2 years ago • 0 comments

Bug report

Description / Observed Behavior

I have multiple endpoints and possible queries for message data fetched via SWR:

  • /api/messages - All messages.
  • /api/messages?archive=true - Archived messages.
  • /api/messages?archive=true&[email protected] - Archived messages from a specific writer
  • ...

This becomes even more complicated when completely different endpoints return the same resource (highlights):

  • /api/highlights - All highlights.
  • /api/messages/[id]/highlights - This message's highlights.
  • /api/highlights?deleted=true - Deleted highlights.
  • ...

Right now, if I update a single highlight, I have to manually mutate each SWR key, see if that highlight existed there, and update it bsaed off existing data like so:

const { mutate } = useSWRInfinite<Highlight[]>(() => `/api/highlights`);
const updated = new Highlight({ text: 'This is an updated highlight!' });
await mutate((res?: Highlight[][]) => res?.map((data: Highlight[]) => {
  const idx = data.findIndex((d) => d.id === updated.id);
  if (idx < 0) return data;
  return [...data.slice(0, idx), updated, ...data.slice(idx + 1)];
}));

Expected Behavior

How did you expect SWR to behave here?

SWR should expose an API to keep track of different resource types and their associated keys. For example, SWR could store a map of resource types and their arrays of keys like so:

const resources = {
  highlight: [`/api/highlights`, `/api/messages/[id]/highlights`, `/api/highlights?deleted=true`],
  message: [`/api/messages`, `/api/messages?archive=true`, `/api/messages?archive=true&[email protected]`],
};

And then SWR would be able to expose an API to easily update a single resource's data across every single possible key that it could've appeared in! I think this is a pretty common use-case: to have an API endpoint that returns a list of data and then another page that updates only a single piece of data in that list.

Repro Steps / Code Example

Here's a custom hook I've been working on that helps with this (it also helps keep track of those mutations so that I don't revalidate when there's mutated list data UNTIL after it's updated server-side):

import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
} from 'react';
import useSWRInfinite, {
  SWRInfiniteConfiguration,
  SWRInfiniteResponse,
} from 'swr/infinite';
import { captureException } from '@sentry/nextjs';
import { mutate as globalMutate } from 'swr';

import { Callback } from 'lib/model/callback';
import { HITS_PER_PAGE } from 'lib/model/query';
import { isHighlightWithMessage } from 'lib/model/highlight';
import { isMessage } from 'lib/model/message';

export type Type = 'highlight' | 'message' | 'account';
export type Mutated = { [type in Type]: boolean };
export type Fetch<T> = Omit<SWRInfiniteResponse<T[]>, 'mutate'> & {
  mutated: (mute: boolean) => void;
  href: string;
  hasMore: boolean;
} & { mutateAll: SWRInfiniteResponse<T[]>['mutate'] } & {
  mutateSingle: (
    resource: T,
    revalidate: boolean
  ) => ReturnType<SWRInfiniteResponse<T[]>['mutate']>;
};

export const MutatedContext = createContext({
  mutated: { highlight: false, message: false, account: false },
  setMutated: (() => {}) as Callback<Mutated>,
});

// Fetch wraps `useSWRInfinite` and keeps track of which resources are being
// fetched (`highlight`). It can then be reused to mutate a single resource and
// unpause revalidations once that mutation has been updated server-side.
export default function useFetch<T extends { id: string | number }>(
  type: Type = 'message',
  url: string = '/api/messages',
  query: Record<string, string> = {},
  options: SWRInfiniteConfiguration = {}
): Fetch<T> {
  const href = useMemo(() => {
    const params = new URLSearchParams(query);
    const queryString = params.toString();
    return queryString ? `${url}?${queryString}` : url;
  }, [query, url]);
  const getKey = useCallback(
    (pageIdx: number, prev: T[] | null) => {
      if (prev && !prev.length) return null;
      if (!prev || pageIdx === 0) return href;
      return `${href}${href.includes('?') ? '&' : '?'}page=${pageIdx}`;
    },
    [href]
  );
  const { mutated, setMutated } = useContext(MutatedContext);
  const { data, mutate, ...rest } = useSWRInfinite<T[]>(getKey, {
    revalidateIfStale: !mutated[type],
    revalidateOnFocus: !mutated[type],
    revalidateOnReconnect: !mutated[type],
    ...options,
  });
  useEffect(() => {
    data?.flat().forEach((resource) => {
      void globalMutate(`${url}/${resource.id}`, resource, false);
    });
  }, [data, url]);
  return {
    ...rest,
    data,
    href,
    hasMore:
      !data || data[data.length - 1].length === HITS_PER_PAGE || mutated[type],
    mutateAll(...args: Parameters<typeof mutate>): ReturnType<typeof mutate> {
      const revalidate = typeof args[1] === 'boolean' ? args[1] : true;
      setMutated((prev) => ({ ...prev, [type]: !revalidate }));
      return mutate(...args);
    },
    mutateSingle(resource: T, revalidate: boolean): ReturnType<typeof mutate> {
      setMutated((prev) => ({ ...prev, [type]: !revalidate }));
      return mutate(
        (response?: T[][]) =>
          response?.map((res: T[]) => {
            const idx = res.findIndex((m) => m.id === resource.id);
            // TODO: Insert this new resource into the correct sort position.
            if (idx < 0) return [resource, ...res];
            try {
              if (isMessage(resource) && resource.archived)
                return [...res.slice(0, idx), ...res.slice(idx + 1)];
            } catch (e) {
              captureException(e);
            }
            try {
              if (isHighlightWithMessage(resource) && resource.deleted)
                return [...res.slice(0, idx), ...res.slice(idx + 1)];
            } catch (e) {
              captureException(e);
            }
            return [...res.slice(0, idx), resource, ...res.slice(idx + 1)];
          }),
        revalidate
      );
    },
    mutated(mute: boolean): void {
      setMutated((prev) => ({ ...prev, [type]: mute }));
    },
  };
}

nicholaschiang avatar Sep 01 '21 17:09 nicholaschiang