Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: Mutate multiple items #1946

Closed
shuding opened this issue Apr 30, 2022 · 2 comments · Fixed by #1989
Closed

RFC: Mutate multiple items #1946

shuding opened this issue Apr 30, 2022 · 2 comments · Fixed by #1989
Assignees
Labels

Comments

@shuding
Copy link
Member

shuding commented Apr 30, 2022

Currently, the global mutate API (imported directly from this package, or returned by useSWRConfig()) allows you to mutate a specific key in the SWR cache:

import { mutate } from 'swr'
// or:
// { mutate } = useSWRConfig()

mutate(key, data)

Where key can be a valid SWR key argument (string | null | undefined | false | any[] | Record<any, any>), or a function that returns a key.

Proposal

In this proposal, we will make it also accept a filter function, and deprecate the case of function that returns a key. This extends the mutate API to filter through all the existing keys and mutate multiple of them in one call.

mutate(key => key.startsWith('/api/'), ...)

This doesn't change the hook-returned mutate API (const { mutate } = useSWR(...)).

API and Use Case

Clear All

One typical use case is to clear or revalidate all keys, this can be done by passing a () => true function:

function onLogout() {
  // Invalidate session
  await logout()

  // Match all keys, set them to undefined, and skip revalidation.
  mutate(() => true, undefined, false)
}

Revalidate All

Some dashboards have the feature of switching the scope. Usually the API URL is still the same after switching the scope, but the authentication token might change and affect the final resource.

We used to handle this kind of things with token as the second argument in keys:

useSWR(['/api/user', token], fetcher)

If we don't include token in the key, when switching the scope the resource won't refresh automatically and might cause the UI being outdated (still showing the previous scope).

But then the pain comes when you want to mutate that resource: mutate(['/api/user', ???]). You'll have to know the token which identifies the resource. It somehow makes sense but in most cases the API is still the unique identifier of the resource here, as we can't have two tokens at the same time in one app instance.

With the extended mutate API, we can solve it this way:

// Use the resource
useSWR('/api/user', url => fetch(url, { headers: {
  // The token can be provided by the current scope or even global,
  // not necessarily bound to the resource.
  Authentication: `Bearer ${token}`
}})...)

// After switching the scope
function changeUser() {
  refreshToken()

  // Invalidate all the resources and revalidate these are currently rendered on the screen.
  mutate(() => true, undefined)
}

We omit the false argument from mutate, which defaults to true, meaning we want to refetch these resources again from the server. And the end user will see "old user → skeleton → new user".

Mutate Only a Subset

Another common case is to only mutate/clear authentication-required resources, but keep the public ones. This can be done by using a filter function:

// Logged out, but users still can see public data.
mutate(key => !key.startsWith('/api/public'), undefined, false)

Also, it can be helpful to mutate resources that belong to the same category, such as a list of pages:

// Uppercase all item names in `/api/items?page=<page>`
mutate(key => /\/api\/items\?page=\d+/.test(key), items =>
  items.map(item => ({ ...item, name: item.name.toUpperCase() }))
)

Open Questions

Simplicity

The most popular use case as far as we know is to clear all the cache, which doesn't look that easy and intuitive (mutate(() => true, undefined, false)). One way to simplify that is to add a shorthand API specifically for that case.

useSWRInfinite

Apparently we need to skip special keys (where we keep the pages array, size and extra context) when doing a match mutate. But how do we tell the "pages state" to revalidate? Like updating the number of pages when you mutate a page to be undefined.

Batching

Consider this common dependent request scenario:

const foo = useSWR('foo').data
const bar = useSWR(foo ? 'bar?foo=' + foo.id : null).data

If we do mutate(() => true, undefined, true). And if the "mutate and revalidate" actions are batched (no render in between), then this will happen:

hook 1: foo → [undefined → revalidation] → foo
hook 2: bar → [undefined → revalidation*] → key changed to `null` → key changed to `bar?foo=foo.id` → revalidation

Which causes an extra (*) revalidation that is unnecessary.

However, if we do the mutation and trigger the revalidation in another tick, we can avoid this problem but there might be other issues yet to explore.

Atomic

If we mutate multiple resources, do we think of them as multiple actions that are unrelated to each other, or one atomic action that affects multiple resources? For the latter we need to combine all revalidations and handle cache populations and error rollbacks after all of them, to keep data consistency.

Key Serialization

SWR accepts objects as the key. For example:

useSWR([ '/api/user', 'uid' ], ...)
useSWR([ getUser, 'uid' ], ...)
useSWR({ api: '/api/user', uid: 'uid' }, ...)

But then internally SWR serializes the object into a string. This makes it impossible to filter by key. One solution is to keep the original key object in cache with a special property name _key, and also add toJSON to keep the cache compatible with things like localStorage. And then, the filter function will accept the original key value:

mutate(key => Array.isArray(key) && key[0] === '/api/user', ...)

However, it forces you to always check the type of the key first when doing a mutate action.

@shuding shuding added the RFC label Apr 30, 2022
@huozhi
Copy link
Member

huozhi commented May 4, 2022

Do you think it's better to only have the function instead of regex?
regex could be used with function as well: (key) => /regex/.test(key)

And someone might prefer other glob patterns: (key) => glob.match('xxx*', key) so that they can pick any pattern they like.

And we only give the original key in the callback of mutate test argument, so that they can compare those keys with the actual ones they hold on their side, instead of compare the serialized keys

@QuyetNX-devf
Copy link

You can consult:

https://swr.vercel.app/docs/advanced/cache

function useMatchMutate() {
  const { cache, mutate } = useSWRConfig()
  return (matcher, ...args) => {
    if (!(cache instanceof Map)) {
      throw new Error('matchMutate requires the cache provider to be a Map instance')
    }

    const keys = []

    for (const key of cache.keys()) {
      if (matcher.test(key)) {
        keys.push(key)
      }
    }

    const mutations = keys.map((key) => mutate(key, ...args))
    return Promise.all(mutations)
  }
}

function Button() {
  const matchMutate = useMatchMutate()
  return <button onClick={() => matchMutate(/^\/api\//)}>
    Revalidate all keys start with "/api/"
  </button>
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants