I have a small app with several kinds of data similar with each other. For example data for labels and statuses. And so the apis are similar too.
Now with react-query, I'm writing many repetitive mutations. All the mutations (add, update, delete) have same structure:
export const useUpdateLabel = () => {
const queryClient = useQueryClient();
return useMutation(updateLabel, {
onSuccess: () => {
queryClient.invalidateQueries("labels");
console.log(`Updated`);
},
onError: (error) => {
process.env.NODE_ENV !== "production" && console.error(error);
},
});
};
I'm using custom hooks to make the code cleaner, but is there any way to reduce the repetitive codes?
I can do something like:
export const useCustomMutation = (func, key) => {
const queryClient = useQueryClient();
return useMutation(func, {
onSuccess: () => {
queryClient.invalidateQueries(key);
console.log(`Updated`);
},
onError: (error) => {
process.env.NODE_ENV !== "production" && console.error(error);
},
});
};
but have no idea how to make the types right.
this should work nicely:
import { useMutation, useQueryClient, QueryKey } from 'react-query'
export const useCustomMutation = <TArguments, TResult>(func: (args: TArguments) => Promise<TResult>, key: QueryKey) => {
const queryClient = useQueryClient();
return useMutation(func, {
onSuccess: () => {
queryClient.invalidateQueries(key);
console.log(`Updated`);
},
onError: (error) => {
process.env.NODE_ENV !== "production" && console.error(error);
},
});
};
link to typescript playground
Related
Description
I'm creating a state management tool for a small project, using mainly useSyncExternalStore from React, inspired by this video from Jack Herrington https://www.youtube.com/watch?v=ZKlXqrcBx88&ab_channel=JackHerrington.
But, I'm running into a pattern that doesn't look right, which is having to use 2 providers, one to create the state, and the other to initialise it.
The gist of the problem:
I have a property sessionId coming from an HTTP request. Saving it in my store wasn't an issue.
However, once I have a sessionId then all of my POST requests done with notifyBackend should have this sessionId in the request body. And I was able to achieve this requirement using the pattern above, but I don't like it.
Any idea how to make it better ?
Code
CreateStore.jsx (Not important, just providing the code in case)
export default function createStore(initialState) {
function useStoreData(): {
const store = useRef(initialState);
const subscribers = useRef(new Set());
return {
get: useCallback(() => store.current, []),
set: useCallback((value) => {
store.current = { ...store.current, ...value };
subscribers.current.forEach((callback) => callback());
}, []),
subscribe: useCallback((callback) => {
subscribers.current.add(callback);
return () => subscribers.current.delete(callback);
}, []),
};
}
const StoreContext = createContext(null);
function StoreProvider({ children }) {
return (
<StoreContext.Provider value={useStoreData()}>
{children}
</StoreContext.Provider>
);
}
function useStore(selector) {
const store = useContext(StoreContext);
const state = useSyncExternalStore(
store.subscribe,
() => selector(store.get()),
() => selector(initialState),
);
// [value, appendToStore]
return [state, store.set];
}
return {
StoreProvider,
useStore,
};
}
Creating the state
export const { StoreProvider, useStore } = createStore({
sessionId: "INITIAL",
notifyBackend: () => { },
});
index.jsx
<Router>
<StoreProvider>
<InitialisationProvider>
<App />
</InitialisationProvider>
</StoreProvider>
</Router
InitialisationContext.jsx
const InitialisationContext = createContext({});
export const InitializationProvider = ({ children }) {
const [sessionId, appendToStore] = useStore(store => store.session);
const notifyBackend = async({ data }) => {
const _data = {
...data,
sessionId,
};
try {
const result = await fetchPOST(data);
if (result.sessionId) {
appendToStore({ sessionId: result.sessionId });
} else if (result.otherProp) {
appendToStore({ otherProp: result.otherProp });
}
} catch (e) { }
};
useEffect(() => {
appendToStore({ notifyBackend });
}, [sessionId]);
return (
<InitialisationContext.Provider value={{}}>
{children}
</InitialisationContext.Provider>
);
}
I just tried out Zustand, and it's very similar to what I'm trying to achieve.
Feels like I'm trying to reinvent the wheel.
With Zustand:
main-store.js
import create from 'zustand';
export const useMainStore = create((set, get) => ({
sessionId: 'INITIAL',
otherProp: '',
notifyBackend: async ({ data }) => {
const _data = {
...data,
sessionId: get().sessionId,
};
try {
const result = await fetchPOST(data);
if (result.sessionId) {
set({ sessionId: result.sessionId });
} else if (result.otherProp) {
set({ otherProp: result.otherProp });
}
} catch (e) { }
},
}));
SomeComponent.jsx
export const SomeComponent() {
const sessionId = useMainStore(state => state.sessionId);
const notifyBackend = useMainStore(state => state.notifyBackend);
useEffect(() => {
if (sessionId === 'INITIAL') {
notifyBackend();
}
}, [sessionId]);
return <h1>Foo</h1>
};
This answer focuses on OPs approach to createStore(). After reading the question a few more times, I think there are bigger issues. I'll try to get to these and then extend the answer.
Your approach is too complicated.
First, the store is no hook! It lives completely outside of react. useSyncExternalStore and the two methods subscribe and getSnapshot are what integrates the store into react.
And as the store lives outside of react, you don't need a Context at all.
Just do const whatever = useSyncExternalStore(myStore.subscribe, myStore.getSnapshot);
Here my version of minimal createStore() basically a global/shared useState()
export function createStore(initialValue) {
// subscription
const listeners = new Set();
const subscribe = (callback) => {
listeners.add(callback);
return () => listeners.delete(callback);
}
const dispatch = () => {
for (const callback of listeners) callback();
}
// value management
let value = typeof initialValue === "function" ?
initialValue() :
initialValue;
// this is what useStore() will return.
const getSnapshot = () => [value, setState];
// the same logic as in `setState(newValue)` or `setState(prev => newValue)`
const setState = (arg) => {
let prev = value;
value = typeof arg === "function" ? arg(prev) : arg;
if (value !== prev) dispatch(); // only notify listener on actual change.
}
// returning just a custom hook
return () => useSyncExternalStore(subscribe, getSnapshot);
}
And the usage
export const useMyCustomStore = createStore({});
// ...
const [value, setValue] = useMyCustomStore();
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 have more of a conceptual question regarding testing-library/react-hooks.
I have the following test:
describe('something else', () => {
const mock = jest.fn().mockResolvedValue({ data: [['NL', 'Netherlands'], ['CU', 'Cuba']], status: 200 });
axios.get = mock;
it('calls the api once', async () => {
const setItemMock = jest.fn();
const getItemMock = jest.fn();
global.sessionStorage = jest.fn();
global.sessionStorage.setItem = setItemMock;
global.sessionStorage.getItem = getItemMock;
const { waitFor } = renderHook(() => useCountries());
await waitFor(() => expect(setItemMock).toHaveBeenCalledTimes(0));
});
});
Which test the following custom hook:
import { useEffect, useState } from 'react';
import axios from '../../shared/utils/axiosDefault';
import { locale } from '../../../config/locales/locale';
type TUseCountriesReturnProps = {
countries: [string, string][];
loading: boolean;
error: string;
}
export default function useCountries(): TUseCountriesReturnProps {
const [countries, setCountries] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const sessionStorageCountriesKey = `countries-${locale}`;
useEffect(() => {
const countriesFromStorage = sessionStorage.getItem(sessionStorageCountriesKey);
const getCountries = async () => {
try {
const response = await axios.get('/api/v3/countries', {
params: {
locale,
},
});
console.log(response);
if (response.status === 200) {
setCountries(response.data);
sessionStorage.setItem(sessionStorageCountriesKey, JSON.stringify(response.data));
} else {
console.error(response);
setError(`Error loading countries, ${response.status}`);
}
} catch (error) {
console.error(error);
setError('Failed to load countries');
}
};
if (!countriesFromStorage) {
getCountries();
} else {
setCountries(JSON.parse(countriesFromStorage));
}
setLoading(false);
}, []);
return {
countries,
loading,
error,
};
}
If I change the toHaveBeenCalledTimes(1) to toHaveBeenCalledTimes(0), all of a sudden I get a Warning: An update to TestComponent inside a test was not wrapped in act(...). on
29 | if (response.status === 200) {
> 30 | setCountries(response.data);
And if I do any number higher than 1, it times out. Even if I extend the timeout time to 30 seconds, it just times out. What is happening here. I just don't understand. And all of that makes me wonder if it is even actually running the test correctly.
Alright, I think I figured it out. For some reason the wait for does not work in this situation. I am now doing it as follows and that works in all scenarios:
describe('useCountries', () => {
describe('when initialising without anything in the sessionStorage', () => {
beforeEach(() => {
axios.get.mockResolvedValue({ data: [['NL', 'Netherlands'], ['CU', 'Cuba']], status: 200 });
global.sessionStorage = jest.fn();
global.sessionStorage.getItem = jest.fn();
});
afterEach(() => {
jest.clearAllMocks();
});
it('calls session storage set item once', async () => {
const setItemMock = jest.fn();
global.sessionStorage.setItem = setItemMock;
const { waitForNextUpdate } = renderHook(() => useCountries());
await waitForNextUpdate();
expect(setItemMock).toHaveBeenCalledTimes(1);
});
});
});
So it seems that testing library wants you to just wait for the first update that happens and not until it stops doing things. As soon as it waits for the final results, other updates trigger and those are somehow messing up something internally.. I wish I could be more explicit about why, but as least waitForNextUpdate seems to have fixed my issue.
export async function onGetNews(){
let data = await axios.get(`${Link}/news`, {
params: {
limit: 1
}
}).then(res => {
return (res.data)
});
return data
}
I tried a lot of solutions and I didn't find a good one. I use limit and other ... and when I use useEffect with export function it gives me an error
export function OnGetServices(){
const [service, setService] = useState([])
useEffect(() => {
setTimeout(async () => {
let data = await axios.get(`${Link}/services`, {}).then(res => {
setService(res.data)
});
}, 1000);
console.log(data);
}, []);
console.log(service);
return service;
}
Why are you doing .then() when you are using async/await? Try this:
export async function onGetNews(){
let res= await axios.get(`${Link}/news`, {
params: {
limit: 1
}
});
return res.data
}
And your react snippet can be:
export function OnGetServices(){
const [service, setService] = useState([])
useEffect(() => {
setTimeout(async () => {
let res = await axios.get(`${Link}/services`, {})
setService(res.data);
console.log(res.data);
}, 1000);
}, []);
}
And if you don't really need the setTimeout, you could change the implementation to:
export function OnGetServices(){
const [service, setService] = useState([])
useEffect(() => {
const fn = async () => {
let res = await axios.get(`${Link}/services`, {})
setService(res.data);
console.log(res.data);
}
fn();
}, []);
}
Async/await drives me crazy either. I wrote a solution, but I'm not sure if it performs good practices. Feedback appreciated.
https://codesandbox.io/s/react-boilerplate-forked-y89eb?file=/src/index.js
If it's a hook then it has to start with the "use" word. Only in a hook, or in a Component, you can use hooks such as useEffect, useState, useMemo.
export function useService(){ //notice the "use" word here
const [service, setService] = useState([])
useEffect(() => {
setTimeout(async () => {
let data = await axios.get(`${Link}/services`, {}).then(res => {
setService(res.data)
});
}, 1000);
console.log(data);
}, []);
console.log(service);
return service;
}
const SomeComponent = () => {
const service = useService();
}
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>
);
}