I have this functional React component:
// CreateNotification.tsx
import {useMutation} from '#apollo/client';
import resolvers from '../resolvers';
const createNotification = (notification) => {
const [createNotification] = useMutation(resolvers.mutations.CreateNotification);
createNotification({
variables: {
movie_id: notification.movie.id,
actor_id: notification.user.id,
message:
`${notification.user.user_name} has added ${notification.movie.original_title} to their watchlist.`,
},
});
};
export default createNotification;
I call the createNotification component in a function and pass in some variables after a other useMutation hook has been called:
// AddMovie.tsx
const addMovie = async (movie: IMovie) => {
await addUserToMovie({
variables: {...movie, tmdb_id: movie.id},
update: (cache, {data}) => {
cache.modify({
fields: {
moviesFromUser: () => {
return [...data.addUserToMovie];
},
},
});
},
}).then( async () => {
createNotification({movie: movie, user: currentUserVar()});
});
};
When I run the code I get the (obvious) error:
Uncaught (in promise) Error: Invalid hook call. Hooks can only be called inside of the body of a function component
Because I call the createNotification hook in the addMovie function.
If I move the createNotification to the top of the component:
// AddMovie.tsx
const AddMovieToWatchList = ({movie}: {movie: IMovie}) => {
createNotification({movie: movie, user: currentUserVar()});
const [addUserToMovie] = useMutation(resolvers.mutations.AddUserToMovie);
const addMovie = async (movie: IMovie) => {
await addUserToMovie({
variables: {...movie, tmdb_id: movie.id},
update: (cache, {data}) => {
cache.modify({
fields: {
moviesFromUser: () => {
return [...data.addUserToMovie];
},
},
});
},
});
};
}
The code works fine, except that the hook is now called every time the AddMovie component is rendered instead of when the addMovie function is called from the click:
return (
<a className={classes.addMovie} onClick={() => addMovie(movie)}>
Add movie to your watchlist
</a>
);
Figured it out:
// createNotification.tsx
import {useMutation} from '#apollo/client';
import resolvers from '../resolvers';
export const createNotification = () => {
const [createNotification, {data, loading, error}] = useMutation(resolvers.mutations.CreateNotification);
const handleCreateNotification = async (notification) => {
createNotification({
variables: {
movie_id: notification.movie.id,
actor_id: notification.user.id,
message:
`${notification.user.user_name} has added ${notification.movie.original_title} to their watchlist.`,
},
});
console.log(data, loading, error);
};
return {
createNotification: handleCreateNotification,
};
};
If I'm correct then this returns a reference (createNotification) to the function handleCreateNotification
Then in the component I want to use the createNotification helper I import it:
// AddMovie.tsx
import {createNotification} from '../../../../helpers/createNotification';
const AddMovieToWatchList = ({movie}: {movie: IMovie}) => {
const x = createNotification();
const addMovie = async (movie: IMovie) => {
await addUserToMovie({
variables: {...movie, tmdb_id: movie.id},
update: (cache, {data}) => {
cache.modify({
fields: {
moviesFromUser: () => {
return [...data.addUserToMovie];
},
},
});
},
}).then( async () => {
x.createNotification({movie: movie, user: currentUserVar()});
});
}
};
You (kind of) answer your own question by showing the error and saying it's obvious. createNotification is not a React component, and it is not a custom hook, it is just a function. Thus using a hook inside of it breaks the Rules of Hooks.
If you want to keep that logic in it's own function, that's fine, just redefine your component like this:
const AddMovieToWatchList = ({movie}: {movie: IMovie}) => {
const [addUserToMovie] = useMutation(resolvers.mutations.AddUserToMovie);
const [createNotification] = useMutation(resolvers.mutations.CreateNotification);
const addMovie = async (movie: IMovie) => {
await addUserToMovie({
variables: {...movie, tmdb_id: movie.id},
update: (cache, {data}) => {
cache.modify({
fields: {
moviesFromUser: () => {
return [...data.addUserToMovie];
},
},
});
},
});
await createNotification({movie: movie, user: currentUserVar()});
};
return (
<a className={classes.addMovie} onClick={() => addMovie(movie)}>
Add movie to your watchlist
</a>
);
}
Related
This is my simplifiedReact component:
export const EntryDetail = () => {
const { articleId } = useParams();
const [article, setArticle] = useState({ title: null, body: null, comments: [], likes: [] });
const { title, body, comments, likes } = article;
useEffect(() => {
(async () => {
try {
const response = await getArticleDetail(articleId);
const { title, body, comments, likes } = response.data;
setArticle({ title, body, comments, likes });
} catch (e) {
console.error(e);
}
})();
}, []);
return (
<Container>
{
!article.title
? <div>Loading...</div>
: <>
<h1>{title}</h1>
<p className="body">{body}</p>
</>
}
</Container>
);
};
And this is my test:
import { render, screen } from '#testing-library/react';
import { StateProvider } from '../../config/state';
import { EntryDetail } from './index';
const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0));
vi.mock('react-router-dom', () => ({
useParams: () => ({
articleId: '63d466ca3d00b50db15aed93',
}),
}));
describe("EntryDetail component", () => {
it("should render the EntryDetail component correctly", async () => {
render(
<EntryDetail />
);
await flushPromises();
const element = screen.getByRole("heading");
expect(element).toBeInTheDocument();
});
});
This is what I'm getting in the console:
I was expecting the "await flushPromises()" would actually wait for the response from the call in the useEffect to the "update" the component", but I guess this is kind of "static"? How should this be handled? I actually want to test if the component itself works effectively, I don't want to mock a response, I want to see if the component actually reacts appropriately after the response is back.
My UI is not updating on the creation of a project with invalidateQueries. I have confirmed that the database updates are being made successfully and the onSuccess functions are being called. I'm unsure what I am doing wrong, and would love some help.
useProjects.ts
import { getProjects } from 'queries/get-projects';
import { useQuery } from 'react-query';
function useGetProjectsQuery() {
return useQuery('projects', async () => {
return getProjects().then((result) => result.data);
});
}
export default useGetProjectsQuery;
get-project.ts
import { supabase } from '../utils/supabase-client';
export const getProjects = async () => {
return supabase.from('projects').select(`*`);
};
useCreateProject.ts
import { useUser } from '#/utils/useUser';
import { createProject } from 'queries/create-project';
import { useMutation, useQueryClient } from 'react-query';
export const useCreateProject = () => {
const { user } = useUser();
const queryClient = useQueryClient();
return useMutation(
({ title, type, bgColorClass, pinned }: any) => {
return createProject(title, type, bgColorClass, pinned, user.id).then(
(result) => result.data
);
},
{
onSuccess: () => {
queryClient.invalidateQueries('projects');
}
}
);
};
create-project.ts
import { supabase } from '../utils/supabase-client';
export async function createProject(
title: string,
type: string,
bgColorClass: string,
pinned: boolean,
userId: string
) {
return supabase
.from('projects')
.insert([
{ title, type, bg_color_class: bgColorClass, pinned, user_id: userId }
]);
}
Home Component
const { data: projects, isLoading, isError } = useGetProjectsQuery();
const createProject = useCreateProject();
const createNewProject = async () => {
await createProject.mutateAsync({
title: projectName,
type: selectedType.name,
bgColorClass: _.sample(projectColors),
pinned: false
});
};
_app.tsx
export default function MyApp({ Component, pageProps }: AppProps) {
const [initialContext, setInitialContext] = useState();
const [supabaseClient] = useState(() =>
createBrowserSupabaseClient<Database>()
);
useEffect(() => {
document.body.classList?.remove('loading');
}, []);
const getUserDetails = async () =>
supabaseClient.from('users').select('*').single();
const getSubscription = async () =>
supabaseClient
.from('subscriptions')
.select('*, prices(*, products(*))')
.in('status', ['trialing', 'active'])
.single();
const getInitialData = async () => {
const userDetails = await getUserDetails();
const subscription = await getSubscription();
setInitialContext({
//#ts-ignore
userDetails: userDetails.data,
subscription: subscription.data
});
};
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 0
}
}
});
useEffect(() => {
getInitialData();
}, []);
return (
<QueryClientProvider client={queryClient}>
<SessionContextProvider supabaseClient={supabaseClient}>
<MyUserContextProvider initial={initialContext}>
<SidebarProvider>
<Component {...pageProps} />
<ReactQueryDevtools initialIsOpen={false} />
</SidebarProvider>
</MyUserContextProvider>
</SessionContextProvider>
</QueryClientProvider>
);
}
I have tried moving the onSuccess followup calls to the Home component, and within the hooks, neither one updates the UI. I'm unsure what I am doing wrong and the react query devtools is not helpful.
You are instantiating QueryClient on every render pass so the cache of query keys is being torn down and rebuilt often.
Instantiate this outside of render:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 0
}
}
});
export default function MyApp({ Component, pageProps }: AppProps) {
const [initialContext, setInitialContext] = useState();
// ... etc
This will ensure the client is always stable.
I am trying to write the test case for an optimistic update in react query. But it's not working. Here is the code that I wrote to test it. Hope someone could help me. Thanks in advance. When I just write the onSuccess and leave an optimistic update, it works fine but here it's not working. And how can we mock the getQueryData and setQueryData here?
import { act, renderHook } from "#testing-library/react-hooks";
import axios from "axios";
import { createWrapper } from "../../test-utils";
import { useAddColorHook, useFetchColorHook } from "./usePaginationReactQuery";
jest.mock("axios");
describe('Testing custom hooks of react query', () => {
it('Should add a new color', async () => {
axios.post.mockReturnValue({data: [{label: 'Grey', id: 23}]})
const { result, waitFor } = renderHook(() => useAddColorHook(1), { wrapper: createWrapper() });
await act(() => {
result.current.mutate({ label: 'Grey' })
})
await waitFor(() => result.current.isSuccess);
})
})
export const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: Infinity,
},
},
logger: {
log: console.log,
warn: console.warn,
error: () => {},
}
});
export function createWrapper() {
const testQueryClient = createTestQueryClient();
return ({ children }) => (
<QueryClientProvider client={testQueryClient}>
{children}
</QueryClientProvider>
);
}
export const useAddColorHook = (page) => {
const queryClient = useQueryClient()
return useMutation(addColor, {
// onSuccess: () => {
// queryClient.invalidateQueries(['colors', page])
// }
onMutate: async color => {
// newHero refers to the argument being passed to the mutate function
await queryClient.cancelQueries(['colors', page])
const previousHeroData = queryClient.getQueryData(['colors', page])
queryClient.setQueryData(['colors', page], (oldQueryData) => {
return {
...oldQueryData,
data: [...oldQueryData.data, { id: oldQueryData?.data?.length + 1, ...color }]
}
})
return { previousHeroData }
},
onSuccess: (response, variables, context) => {
queryClient.setQueryData(['colors', page], (oldQueryData) => {
console.log(oldQueryData, 'oldQueryData', response, 'response', variables, 'var', context, 'context', 7984)
return {
...oldQueryData,
data: oldQueryData.data.map(data => data.label === variables.label ? response.data : data)
}
})
},
onError: (_err, _newTodo, context) => {
queryClient.setQueryData(['colors', page], context.previousHeroData)
},
onSettled: () => {
queryClient.invalidateQueries(['colors', page])
}
})
}
The error that you are getting actually shows a bug in the way you've implemented the optimistic update:
queryClient.setQueryData(['colors', page], (oldQueryData) => {
return {
...oldQueryData,
data: [...oldQueryData.data, { id: oldQueryData?.data?.length + 1, ...color }]
}
})
what if there is no entry in the query cache that matches this query key? oldQueryData will be undefined, but you're not guarding against that, you are spreading ...oldQueryData.data and this will error out at runtime.
This is what happens in your test because you start with a fresh query cache for every test.
An easy way out would be, since you have previousHeroData already:
const previousHeroData = queryClient.getQueryData(['colors', page])
if (previousHeroData) {
queryClient.setQueryData(['colors', page], {
...previousHeroData,
data: [...previousHeroData.data, { id: previousHeroData.data.length + 1, ...color }]
}
}
If you are using TanStack/query v4, you can also return undefined from the updater function. This doesn't work in v3 though:
queryClient.setQueryData(['colors', page], (oldQueryData) => {
return oldQueryData ? {
...oldQueryData,
data: [...oldQueryData.data, { id: oldQueryData?.data?.length + 1, ...color }]
} : undefined
})
This doesn't perform an optimistic update then though. If you know how to create a valid cache entry from undefined previous data, you can of course also do that.
I am experiencing the below error when I introduce useQueryClient? Any ideas why this may be?
I am trying to invalidateQueries for a queryKey onSuccess of the useUpdateEmployee hook.
bundle.js:1427 Uncaught Error: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
Component
import { useFetchEmployee, useUpdateEmployee } from '../Users/Usershooks';
const User = () => {
const userData = {
name: 'test'
};
const { data } = useFetchEmployee(userID);
const { mutate } = useUpdateEmployee(userID, userData);
const saveChangesOnClick = () => {
mutate();
};
return (
<div>
...
</div>
);
};
export default User;
HookFile
import axios from 'axios';
import { useMutation, useQuery, useQueryClient } from 'react-query';
const queryClient = useQueryClient();
export const useFetchEmployers = () => useQuery(['fetchEmployers'], () => axios.get('https://jsonplaceholder.typicode.com/users')
.then(response => response.data));
export const useFetchEmployee = (userID: any) => useQuery(['fetchEmployers', userID], () => axios.get(`https://jsonplaceholder.typicode.com/users/${userID}`)
.then(response => response.data));
export const useUpdateEmployee = (userID: any, userData: any) => useMutation(
() => axios.put(`https://jsonplaceholder.typicode.com/users/${userID}`, userData)
.then(response => response.data),
{
onSuccess: () => {
console.log("success");
queryClient.invalidateQueries(['fetchEmployers']);
}
}
);
useQueryClient is a hook, it has to be initialized in a React component or in a custom hook. Just move it inside the useUpdateEmployee.
export const useUpdateEmployee = (userID: any, userData: any) => {
const queryClient = useQueryClient();
return useMutation(
...,
onSuccess: () => {
queryClient.invalidateQueries(['fetchEmployers']);
}
);
};
i have a simple hook that fetches the value and sets it to option as follows:
import Fuse from 'fuse.js'
import React from 'react'
// prefetches options and uses fuzzy search to search on that option
// instead of fetching on each keystroke
export function usePrefetchedOptions<T extends {}>(fetcher: () => Promise<T[]>) {
const [options, setOptions] = React.useState<T[]>([])
React.useEffect(() => {
// fetch options initially
const optionsFetcher = async () => {
try {
const data = await fetcher()
setOptions(data)
} catch (err) {
errorSnack(err)
}
}
optionsFetcher()
}, [])
// const fuseOptions = {
// isCaseSensitive: false,
// keys: ['name'],
// }
// const fuse = new Fuse(options, fuseOptions)
// const dataServiceProxy = (options) => (pattern: string) => {
// // console.error('options inside proxy call', { options })
// const optionsFromSearch = fuse.search(pattern).map((fuzzyResult) => fuzzyResult.item)
// return new Promise((resolve) => resolve(pattern === '' ? options : optionsFromSearch))
// }
return options
}
i am trying to test it with following code:
import { act, renderHook, waitFor } from '#testing-library/react-hooks'
import { Wrappers } from './test-utils'
import { usePrefetchedOptions } from './usePrefetchedOptions'
import React from 'react'
const setup = ({ fetcher }) => {
const {
result: { current },
waitForNextUpdate,
...rest
} = renderHook(() => usePrefetchedOptions(fetcher), { wrapper: Wrappers })
return { current, waitForNextUpdate, ...rest }
}
describe('usePrefetchedOptions', () => {
const mockOptions = [
{
value: 'value1',
text: 'Value one',
},
{
value: 'value2',
text: 'Value two',
},
{
value: 'value3',
text: 'Value three',
},
]
test('searches for appropriate option', async () => {
const fetcher = jest.fn(() => new Promise((resolve) => resolve(mockOptions)))
const { rerender, current: options, waitForNextUpdate } = setup({ fetcher })
await waitFor(() => {
expect(fetcher).toHaveBeenCalled()
})
// async waitForNextUpdate()
expect(options).toHaveLength(3) // returns initial value of empty options = []
})
})
the problem is when i am trying to assert the options at the end of the test, it still has the initial value of []. However if I log the value inside the hook, it returns the mockOptions. How do I update the hook after it is update by useEffect but in async manner.
I have also tried using using waitForNextUpdate where it is commented in the code. it times out with following error:
Timeout - Async callback was not invoked within the 5000 ms timeout specified by jest.setTimeout.Timeout - Async callback was not invoked within the 5000 ms timeout specified by jest.setTimeout.Error:
Couple things, currently you're waiting for fetcher to be called in your tests, but the state update actually happens not after fetcher is called but after the promise that fetcher returns is resolved. So you'd need to wait on the resolution of that promise in your test
Also, you're destructuring the value of result.current when you first render your hook. That value is just a copy of result.current after that first render and it will not update after that. To query the current value of options, you should query result.current in your assertion instead.
const fetcherPromise = Promise.resolve(mockOptions);
const fetch = jest.fn(() => fetcherPromise);
const { result } = renderHook(() => usePrefetchedOptions(fetcher), { wrappers: Wrappers })
await act(() => fetcherPromise);
expect(result.current).toHaveLength(3)
Here's what worked for me whenI needed to test the second effect of my context below:
import React, {createContext, useContext, useEffect, useState} from "react";
import {IGlobalContext} from "../models";
import {fetchGravatar} from "../services";
import {fetchTokens, Token} from "#mylib/utils";
const GlobalContext = createContext<IGlobalContext>({} as IGlobalContext);
function useGlobalProvider(): IGlobalContext {
const [token, setToken] = useState<Token>(Token.deserialize(undefined));
const [gravatar, setGravatar] = useState<string>('');
useEffect(() => {
setToken(fetchTokens());
}, []);
useEffect(() => {
if (token?.getIdToken()?.getUsername()) {
fetchGravatar(token.getIdToken().getUsername())
.then(setGravatar)
}
}, [token]);
const getToken = (): Token => token;
const getGravatar = (): string => gravatar;
return {
getToken,
getGravatar
}
}
const GlobalProvider: React.FC = ({children}) => {
const globalContextData: IGlobalContext = useGlobalProvider();
return (
<GlobalContext.Provider value={globalContextData}>{children}</GlobalContext.Provider>
);
};
function useGlobalContext() {
if (!useContext(GlobalContext)) {
throw new Error('GlobalContext must be used within a Provider');
}
return useContext<IGlobalContext>(GlobalContext);
}
export {GlobalProvider, useGlobalContext};
corresponding tests:
import React from "react";
import {GlobalProvider, useGlobalContext} from './Global';
import {act, renderHook} from "#testing-library/react-hooks";
import utils, {IdToken, Token} from "#mylib/utils";
import {getRandomGravatar, getRandomToken} from 'mock/Token';
import * as myService from './services/myService';
import {Builder} from "builder-pattern";
import faker from "faker";
jest.mock('#mylib/utils', () => ({
...jest.requireActual('#mylib/utils')
}));
describe("GlobalContext", () => {
it("should set Token when context loads", () => {
const expectedToken = getRandomToken('mytoken');
const spyFetchToken = spyOn(utils, 'fetchTokens').and.returnValue(expectedToken);
const wrapper = ({children}: { children?: React.ReactNode }) => <GlobalProvider>{children} </GlobalProvider>;
const {result} = renderHook(() => useGlobalContext(), {wrapper});
expect(spyFetchToken).toHaveBeenCalled();
expect(result.current.getToken()).toEqual(expectedToken);
})
it("should fetch Gravatar When Token username changes", async () => {
const expectedToken = getRandomToken('mytoken');
const expectedGravatar = getRandomGravatar();
const returnedGravatarPromise = Promise.resolve(expectedGravatar);
const spyFetchToken = spyOn(utils, 'fetchTokens').and.returnValue(expectedToken);
const spyFetchGravatar = spyOn(myService, 'fetchGravatar').and.returnValue(returnedGravatarPromise);
const wrapper = ({children}: { children?: React.ReactNode }) =>
<GlobalProvider>{children} </GlobalProvider>;
const {result, waitForValueToChange} = renderHook(() => useGlobalContext(), {wrapper});
// see here
// we need to wait for the promise to be resolved, even though the gravatar spy returned it
let resolvedGravatarPromise;
act(() => {
resolvedGravatarPromise = returnedGravatarPromise;
})
await waitForValueToChange(() => result.current.getGravatar());
expect(spyFetchToken).toHaveBeenCalled();
expect(result.current.getToken()).toEqual(expectedToken);
expect(spyFetchGravatar).toHaveBeenCalledWith(expectedToken.getIdToken().getUsername());
expect(resolvedGravatarPromise).toBeInstanceOf(Promise);
expect(result.current.getGravatar()).toEqual(expectedGravatar);
})
})