5

I'm using the swr package. I successfully fetch data using useSWR, but when I try to mutate the data - it does not work, and the cached state of swr does not change (as it should).

I created my custom hook:

import useSWR from 'swr';

import BackendService from '@/services/backend';

const useBackend = <D, E = unknown>(path: string | null) => {
    const { data, error, isLoading, mutate } = useSWR<D, E>(path, BackendService.get);

    return { data, error, isLoading, mutate };
};

export default useBackend;

And this is my BackendService:

import { preload } from 'swr';

import type { IHttpMethod } from '@/interfaces/http';

class BackendService {
    private static routesWithRefreshToken: string[] = ['/user/auth'];

    private static fetcher = async <R = unknown, D = unknown>(
        path: string,
        method: IHttpMethod,
        data?: D,
    ) => {
        const requestPath = import.meta.env.VITE_BACKEND_URL + path;
        const withRefresh = this.routesWithRefreshToken.includes(path);
        const token = withRefresh ? localStorage.getItem('token') : sessionStorage.getItem('token');

        const res = await fetch(requestPath, {
            method,
            headers: {
                'Authorization': `Bearer ${token}`,
                'Content-Type': 'application/json',
            },
            body: data ? JSON.stringify(data) : undefined,
        });

        if (!res.ok) {
            throw new Error();
        }

        const resData = await res.json().catch(() => undefined);

        return resData as R;
    };

    public static get = <R = unknown>(path: string) => {
        return this.fetcher<R, null>(path, 'GET');
    };

    public static post = <R = unknown, D = unknown>(path: string, data?: D) => {
        return this.fetcher<R, D>(path, 'POST', data);
    };

    public static patch = <R = unknown, D = unknown>(path: string, data?: D) => {
        return this.fetcher<R, D>(path, 'PATCH', data);
    };

    public static delete = <R = unknown>(path: string) => {
        return this.fetcher<R, null>(path, 'DELETE');
    };

    public static preload = (path: string) => {
        return preload(path, this.get);
    };
}

export default BackendService;

Now, I have the following code:

import React, { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import type { IGetAllSecretsResponseData } from '@exlint.io/common';

import useBackend from '@/hooks/use-backend';
import BackendService from '@/services/backend';

import SecretManagementView from './SecretManagement.view';

interface IProps {}

const SecretManagement: React.FC<IProps> = () => {
    const navigate = useNavigate();

    const { data: getAllSecretsResponseData, mutate: getAllSecretsMutate } =
        useBackend<IGetAllSecretsResponseData>('/user/secrets');

    const hasSecrets = useMemo(() => {
        if (!getAllSecretsResponseData) {
            return false;
        }

        return getAllSecretsResponseData.secrets.length > 0;
    }, [getAllSecretsResponseData]);

    const onRevokeAllSecrets = async () => {
        await getAllSecretsMutate(
            async () => {
                await BackendService.delete('/user/secrets');

                return {
                    secrets: [],
                };
            },
            {
                optimisticData: { secrets: [] },
                rollbackOnError: true,
            },
        );

        navigate('', { replace: true });
    };

    return <SecretManagementView hasSecrets={hasSecrets} onRevokeAllSecrets={onRevokeAllSecrets} />;
};

SecretManagement.displayName = 'SecretManagement';
SecretManagement.defaultProps = {};

export default React.memo(SecretManagement);

So when the onRevokeAllSecrets is executed - the cached state does not change.

Could anyone tell why? I checked and my BackendService.delete call completes successfully.


I have also tried to change my custom hook useBackend as follows:

import { useCallback } from 'react';
import useSWR, { useSWRConfig, type KeyedMutator } from 'swr';

import BackendService from '@/services/backend';

const useBackend = <D, E = unknown>(path: string | null) => {
    const { mutate: globalMutate } = useSWRConfig();
    const { data, error, isLoading } = useSWR<D, E>(path, BackendService.get);

    const mutator: KeyedMutator<D> = useCallback(
        (data, options) => globalMutate(path, data, options),
        [path],
    );

    return {
        data,
        error,
        isLoading,
        mutate: mutator,
    };
};

export default useBackend;

But it still didn't help (same exact issue)


I have also tried to provide revalidate to the mutation:

        await getAllSecretsMutate(
            async () => {
                await BackendService.delete('/user/secrets');

                return { secrets: [] };
            },
            {
                optimisticData: { secrets: [] },
                rollbackOnError: true,
                revalidate: false,
            },
        );

Then it worked - the cache did change. But I navigate to other page and then come back - the cache is invalid again.

Anyway, if adding revalidate: false somehow does update the cache, then I'd say that when I used revalidate: true (default) the revalidation once the asynchronous update resolves brings non-empty data from my server? But that's it - I checked the response from the server and it responded with empty array:

enter image description here


Also, I don't think the behavior of static methods causes that issue because I refactored the BackendService to something like this:

import { preload } from 'swr';

import type { IHttpMethod, IRefreshTokenRoute } from '@/interfaces/http';

const routesWithRefreshToken: IRefreshTokenRoute[] = [
    { method: 'GET', path: '/user/auth' },
    { method: 'GET', path: '/user/auth/refresh-token' },
];

const fetcher = async <R = unknown, D = unknown>(path: string, method: IHttpMethod, data?: D) => {
    const endpointPath = import.meta.env.VITE_BACKEND_URL + path;

    const withRefresh = !!routesWithRefreshToken.find(
        (route) => route.method === method && route.path === path,
    );

    const token = (withRefresh ? localStorage : sessionStorage).getItem('token');

    const res = await fetch(endpointPath, {
        method,
        headers: {
            'Authorization': `Bearer ${token}`,
            'Content-Type': 'application/json',
        },
        body: data ? JSON.stringify(data) : undefined,
    });

    if (!res.ok) {
        throw new Error();
    }

    const resData = await res.json().catch(() => undefined);

    return resData as R;
};

const get = <R = unknown>(path: string) => {
    return fetcher<R, null>(path, 'GET');
};

const post = <R = unknown, D = unknown>(path: string, data?: D) => {
    return fetcher<R, D>(path, 'POST', data);
};

const patch = <R = unknown, D = unknown>(path: string, data?: D) => {
    return fetcher<R, D>(path, 'PATCH', data);
};

const deleter = <R = unknown>(path: string) => {
    return fetcher<R, null>(path, 'DELETE');
};

const preloader = (path: string) => {
    return preload(path, get);
};

const BackendService = {
    fetcher,
    get,
    post,
    patch,
    deleter,
    preloader,
};

export default BackendService;

And it did not help.

Tal Rofe
  • 457
  • 12
  • 46

3 Answers3

0

Is this an issue with your backend?

SWR expects that the value you give it with mutate is the same as or your best approximation of what it would get if it called GET /user/secrets again.

The revalidate feature is then used to ensure the cached data is the most up to date data provided by your fetcher function (GET /user/secrets).

When you provide revalidate: false as one of the options to mutate it will not grab the most up to date data provided by your fetcher function (GET /user/secrets) and instead assume that you guarantee your provided value is up to date.

When mounting a component with useSWR its default configuration is set to revalidate the data if it is not already in the process of doing so. This is why when you navigated away from and back to the page in question the cached data was updated to the data provided by your fetcher function (GET /user/secrets).

Mutate Scenario:

  1. Data X retrieved via GET /user/secrets stored in cache.
  2. mutate is triggered.
  3. DELETE /user/secrets is called.
  4. Empty Array stored in cache.
  5. revalidation is triggered.
  6. Data Y (possibly the same as Data X) retrieved via GET /user/secrets stored in cache.

Navigate Scenario:

  1. Data X retrieved via GET /user/secrets stored in cache.
  2. mutate is triggered.
  3. DELETE /user/secrets is called.
  4. Empty Array stored in cache.
  5. User navigates away from page, unmounting SecretManagement.
  6. User navigates to page, mounting SecretManagement.
  7. revalidation is triggered.
  8. Data Y (possibly the same as Data X) retrieved via GET /user/secrets stored in cache.

TLDR:

With how you are attempting to use SWR it can be assumed that after DELETE /user/secrets is called that GET /user/secrets would return an empty array (which it does not seem to be doing).

Jacob Smit
  • 2,206
  • 1
  • 9
  • 19
  • I understand what you say and it seems as it, **BUT** yet again, when I removed the `revalidate: false` from my code, I checked the `Network` tab and I saw the revalidation response is indeed an empty array. – Tal Rofe Mar 21 '23 at 06:39
  • it's possible that the issue is related to the revalidation timing or some internal caching mechanism within SWR. you can add a custom fetcher to your useBackend hook, and if the revalidation still doesn't seem to update the cache, you can try to force revalidate the data after the mutation has completed by manually calling the mutate() function without any arguments – Stefan Mar 21 '23 at 08:02
  • @Stefan What do you mean by custom fetcher? I did add my own fetcher – Tal Rofe Mar 21 '23 at 08:39
  • @TalRofe You're right, you have already implemented a custom fetcher in your BackendService. In my previous response, the term "custom fetcher" was used to refer to a slightly modified version of your fetcher function that logs the results of the revalidation for debugging purposes. Just wrap your existing BackendService.get method and log the fetched data before returning it. – Stefan Mar 23 '23 at 14:06
  • @Stefan No problem, I'll try. In the mean time I can update you that manually calling `mutate()` without arguments solved it. But it makes 2 API calls instead of one. So this is good but not perfect. – Tal Rofe Mar 23 '23 at 16:14
  • @TalRofe Try to perform the mutation and then manually update the cache without triggering a revalidation or if you still want to perform a revalidation but avoid making an additional API call immediately after the mutation, you can use the mutate function from the useSWRConfig hook instead of the keyed mutator – Stefan Mar 24 '23 at 08:55
  • 1
    @Stefan The second API request is cached so I consider it fair enough. If you want, sum up all of your points here into an answer and I will mark your answer as accepted – Tal Rofe Mar 24 '23 at 12:08
0

I have put your code into a sandbox. with a mocked fetch, and it works...

might there be a bug in the backend delete code?

when you call SWR mutate it will put the returned value in the cache AND re-fetch the data, call get("user/secrets") again. see docs.

If you set revalidate: false, SWR won't re-fetch the data, and you will see the optimistically updated value (until the component is remounted - navigate - and the data is re-fetched). This is what makes me think that the backend delete doesn't really delete the secrets.

hope it helps

Alissa
  • 1,824
  • 1
  • 12
  • 15
  • As I said, when I inspect the network tab and I see the response of the revalidation - is does have the empty array. – Tal Rofe Mar 22 '23 at 13:52
  • the response to `get("/user/secrets") is {secrets: []} ? I can't debug your code :) did you open the sandbox? it is your code exactly (except mock fetch and base path) – Alissa Mar 22 '23 at 14:31
  • can you add the network responses? – Alissa Mar 22 '23 at 14:39
  • The network response is indeed `{ secrets: [] }` – Tal Rofe Mar 22 '23 at 18:19
  • I created a sandbox by myself and I could not replicate the issue with same code blocks. I really don't understand how come the cache not update. Anyway - yes your sandbox is quiet good. – Tal Rofe Mar 22 '23 at 18:22
  • I'm sorry, I don't think that I can help you without being able to reproduce it :( maybe if you show the whole network communication around the event. maybe try to create another component that just uses regular fetch and see if it works – Alissa Mar 22 '23 at 18:45
0

I resolved that issue by forcing mutation:

import useSWR, { type KeyedMutator } from 'swr';
import { useCallback } from 'react';

import BackendService from '@/services/backend';

const useBackend = <D, E = unknown>(path: string | null) => {
    const { data, error, isLoading, mutate } = useSWR<D, E>(path, BackendService.get);

    /**
     * * Forced mutation is required for synchronization issues,
     * * as noted here: https://stackoverflow.com/a/75796537/9105207
     * * The second mutation call uses cache therefore not extra API call network is made
     */
    const forcedMutation: KeyedMutator<D> = useCallback(
        async (mutationData, options) => {
            const data = await mutate(mutationData, options);

            await mutate();

            return data;
        },
        [mutate],
    );

    return { data, error, isLoading, mutate: forcedMutation };
};

export default useBackend;

Please read the comments to understand more..

Tal Rofe
  • 457
  • 12
  • 46