React: Query, delay useReducer - reactjs

I've been scratching my head around this one for quite some time now, but I'm still not sure what to do.
Basically, I'm pulling data from a database via useQuery, which all works well and dandy, but I'm also trying to use useReducer (which I'm still not 100% familiar with) to save the initial data as the state so as to detect if any changes have been made.
The problem:
While the useQuery is busy fetching the data, the initial data is undefined; and that's what's being saved as the state. This causes all sorts of problems with regards to validation amd saving, etc.
Here's my main form function:
function UserAccountDataForm({userId}) {
const { query: {data: userData, isLoading: isLoadingUserData} } = useUserData(userId);
const rows = React.useMemo(() => {
if (userData) { /* Process userData here into arrays */ }
return [];
}, [isLoadingUserData, userData]); // Watches for changes in these values
const { isDirty, methods } = useUserDataForm(handleSubmit, userData);
const { submit, /* updateFunctions here */ } = methods;
if (isLoadingUserData) { return <AccordionSkeleton /> } // Tried putting this above useUserDataForm, causes issues
return (
<>
Render stuff here
*isDirty* is used to detect if changes have been made, and enables "Update Button"
</>
)
}
Here's useUserData (responsible for pulling data from the DB):
export function useUserData(user_id, column = "data") {
const query = useQuery({
queryKey: ["user_data", user_id],
queryFn: () => getUserData(user_id, column), // calls async function for getting stuff from DB
staleTime: Infinity,
});
}
return { query }
And here's the reducer:
function userDataFormReducer(state, action) {
switch(action.type) {
case "currency":
return {... state, currency: action.currency}
// returns data in the same format as initial data, with updated currency. Of course if state is undefined, formatting all goes to heck
default:
return;
}
}
function useUserDataForm(handleSubmit, userData) {
const [state, dispatch] = React.useReducer(userDataFormReducer, userData);
console.log(state) // Sometimes this returns the data as passed; most of the times, it's undefined.
const isDirty = JSON.stringify(userData) !== JSON.stringify(state); // Which means this is almost always true.
const updateFunction = (type, value) => { // sample only
dispatch({type: type, value: value});
}
}
export { useUserDataForm };
Compounding the issue is that it doesn't always happen. The main form resides in a <Tab>; if the user switches in and out of the tab, sometimes the state will have the proper initial data in it, and everything works as expected.
The quickest "fix" I can think of is to NOT set the initial data (by not calling the reducer) while useQuery is running. Unfortunately, I'm not sure this is possible. Is there anything else I can try to fix this?

Compounding the issue is that it doesn't always happen. The main form resides in a ; if the user switches in and out of the tab, sometimes the state will have the proper initial data in it, and everything works as expected.
This is likely to be expected because useQuery will give you data back from the cache if it has it. So if you come back to your tab, useQuery will already have data and only do a background refetch. Since the useReducer is initiated when the component mounts, it can get the server data in these scenarios.
There are two ways to fix this:
Split the component that does the query and the one that has the local state (useReducer). Then, you can decide to only mount the component that has useReducer once the query has data already. Note that if you do that, you basically opt out of all the cool background-update features of react-query: Any additional fetches that might yield new data will just not be "copied" over. That is why I suggest that IF you do that, you turn off the query to avoid needless fetches. Simple example:
const App = () => {
const { data } = useQuery(key, fn)
if (!data) return 'Loading...'
return <Form data={data} />
}
const Form = ({ data }) => {
const [state, dispatch] = useReducer(userDataFormReducer, data)
}
since the reducer is only mounted when data is available, you won't have that problem.
Do not copy server state anywhere :) I like this approach a lot better because it keeps server and client state separate and also works very well with useReducer. Here is an example from my blog on how to achieve that:
const reducer = (amount) => (state, action) => {
switch (action) {
case 'increment':
return state + amount
case 'decrement':
return state - amount
}
}
const useCounterState = () => {
const { data } = useQuery(['amount'], fetchAmount)
return React.useReducer(reducer(data ?? 1), 0)
}
function App() {
const [count, dispatch] = useCounterState()
return (
<div>
Count: {count}
<button onClick={() => dispatch('increment')}>Increment</button>
<button onClick={() => dispatch('decrement')}>Decrement</button>
</div>
)
}
If that works is totally dependent on what your reducer is trying to achieve, but it could look like this:
const App = () => {
const { data } = useQuery(key, fn)
const [state, dispatch] = useReducer(userDataFormReducer)
const currency = state.currency ?? data.currency
}
By keeping server state and client state separately, you'll only store what the user has chosen. The "default values" like currency stay out of the state, as it would essentially be state duplication. If the currency is undefined, you can still choose to display the server state thanks to the ?? operator.
Another advantage is that the dirty check is relatively easy (is my client state undefined?) and resets to the initial state also just mean to set the client state back to undefined.
So the actual state is essentially a computed state from what you have from the server and what the user has input, giving precedence to the user input of course.

Related

Hook with abortable fetch

note: I am aware of the useAbortableFetch hook. Trying to recreate a simple version of it.
I am trying to create a hook that returns a function that can make an abortable fetch request.
Idea being I want this hook to hold the state and update it when needed.
The update part is controlled by another competent on input change
What I am working on currently is
function useApiData(baseUrl){
const [state, setState] = use state({
data: null,
error: null,
loading: false
})
const controller = useRef(new AbortController)
const fetchData = searchTerm => {
if(state.loading){
controller.current.abort()
controller.current = new AbortController;
}
const signal = controller.signal;
setState(state => ({...state, loading: true})
fetch(url + searchTerm, {signal})
.then(res => res.json())
.then(data => {
setState(state => ({...state, data}))
return data
})
.catch(error => {
setState(state => ({...state, error}))
})
.finally(() => setState({...state, loading: false}))
}
const fetchCallback = useCallback(debounce(fetchData, 500), [])
return {...state, search: fetchCallback}
}
Usage
function App(){
const dataState = useApiData(url);
return ComponentWithInputElement {...dataState} />
}
function ComponentWithInputElement(props){
const [value, setValue] = useState('')
const onInput = ev => {
setValue(ev.target.value)
props.search(ev.tagert.value)
}
return (
<>
<input value={value} on input={onInput}>
{props.data?.length && <render datacomp>}
</>
)
}
This seems to fail to even send the first request.
Any way to make this pattern work?
Doing this in a useEffect would be very simple but I won't have access to the input value to have it as a dep
useEffect(()=>{
const controller = new AbortController();
const signal = controller.signal
fetch(url + value, {signal})
return () => controller.abort()
},[value])
Part of what you are trying to do does not feel "right". One of the things you are trying to do is have the state of the input value (like the form state) stored in the same hook. But those are not the same bits of state, as when the user types, it is (temporarily until its saved back to the server) different to the state fetched from the server. If you reuse the same state item for both, in the process of typing in the field, you lose the state fetched from the server.
You may think, "but I don't need it any more" -- but that often turns out to be a false abstraction later when new requirements come about that require it (like you need to display some static info as well as an editable form). In that sense, in the long term it would likely be less reusable.
It's a classic case of modelling an abstraction around a single use case -- which is a common pitfall.
You could add a new state item to the core hook to manage this form state, but then you have made it so you can only ever have the form state at the same level as the fetched data -- which may work in some cases, but be "overscoping" in others.
This is basically how all state-fetch libs like react query work -- Your fetched data is separate to the form data. And the form data is just initialised from the former as its initial value. But the input is bound to that "copy".
What you want is possible if you just returned setState from an additional state item in the core hook then passed down that setState to the child to be used as a change handler. You would then pass down the actual form string value from this new state from the parent to the child and bind that to the value prop of the input.
However, I'd encourage against it, as its an architectural flaw. You want to keep your local state, and just initialise it from the fetched state. What I suggested might be OK if you intend to use it only in this case, but your answer implies reuse. I guess I would need more info about how common this pattern is in your app.
As for abort -- you just need to return the controller from the hook so the consumer can access it (assuming you want to abort in the consumers?)

Unexpected result from apollo react's useQuery method

For a project, where i've implemented authentication by running a GraphQL query inside a AuthenticationProvider from a context, I noticed the query is fetching data twice.
const AuthenticationProvider: FC = props => {
const {
loading,
data
} = useQuery(MeQuery)
if (loading) return null
return <AuthenticationContext.Provider value={{user: data?.me || null}} {...props} />
}
However the query runs perfect, I still wanted to know why it fetches the data twice. I did some googling, and came across this issue, where this answer was provided. I tried the same thing, with the skip option, based if the data is loaded.
const [skip, setSkip] = useState(false)
const {
loading,
data
} = useQuery(MeQuery, { skip })
useEffect(() => {
if (!loading && data?.me) {
setSkip(true)
}
}, [loading, data])
// ...
But when logging in, it stopped working.
const useLoginMutation = () => useMutation(LOGIN_QUERY, { update: (cache, { data }) => {
if (!data) {
return null
}
cache.writeQuery({ query: MeQuery, data: { me: data.login } })
}
})
The cache still get's updated with the right values, but doesn't retrieve the user anymore (null).
const { user } = useContext(AuthenticationContext)
What am I missing here? It seems the query did run and fetched the correct data.
You don't need to use context when you are using apollo useQuery. If you make a query first, then the data fetched will be stored in the cache. You can directly access the data from the cache for the second you run the query. Since useQuery has default fetchPolicy cache-first. Mean its check in the cache first, if no query made before it makes a network request.
If you want to avoid network loading. You can make a top-level component AuthWrapper.
const useUserQuery = () => useQuery(ME_QUERY);
const AuthWrapper = ({children}) => {
const {loading, data} = useUserQuery();
if(loading) return null
return children;
}
export GetUsetInThisComponent = ({}) => {
// Since we have fetched the user in AuthWrapper, the user will be fetched from the cache.
const {data} = useUserQuery();
// No you can access user from data?.user
// Rest of the application logic
}
// Wrap the component like this to avoid loading in the children components
<AuthWrapper>
<GetUserInThisComponent />
</AuthWrapper>

Special actions on React state variables

I have a React component with a state variable that needs specific actions. For example, consider a component that shows a list of user profiles, and the user can switch to another profile or create a new one. The state variable is a list of user profiles, and a second variable is the currently selected profile; the component can add a new profile (which is more specific than just "setting" a new list of profiles), or it can change the currently selected profile.
My first idea was to have two useState hooks, one for the list and one for the current profile. However, one problem with that is that I would like to store the current profile's id, which refers to one of the profiles in the list, which means that the two state variables are inter-dependent. Another issue is that having a generic setProfiles state change function is a bit too "open" for my taste: the add logic may be very specific and I would like to encapsulate it.
So I came up with this solution: a custom hook managing the two state variables and their setters, that would expose the two values (list and current id) and their appropriate actions (add new profile and select profile).
This is the code of the hook:
export const useProfileData = () => {
const [ profiles, setProfiles ] = useState([]);
const [ currentProfileID, setCurrentProfileID ] = useState(null);
const [ currentProfile, setCurrentProfile ] = useState(null);
useEffect(() => {
// This is actually a lazy deferred data fetch, but I'm simplifying for the sake of brevity
setProfiles(DataManager.getProfiles() || [])
}, [])
useEffect(() => {
if (!profiles) {
setCurrentProfile(null);
return;
}
const cp = profiles.find(p => p.ID === currentProfileID);
setCurrentProfile(cp);
}, [ currentProfileID, profiles ])
return {
currentProfile: currentProfile,
profiles: profiles,
setCurrentProfileID: i_id => setCurrentProfileID(i_id),
addNewProfile: i_profile => {
profiles.push(i_profile);
setProfiles(profiles);
DataManager.addNewProfile(i_profile); // this could be fire-and-forget
},
};
};
Three states are used: the list, the current profile id and the current profile (as an object). The list is retrieved at mounting (the current id should be too, but I omitted that for brevity). The current profile is never set directly from the outside: the only way to change it is to change the id or the list, which is managed by the second useEffect. And the only way to change the id is through the exposed setCurrentProfileID function.
Adding a new profile is managed by an exposed addNewProfile function, that should add the new profile to the list in state, update the list in state, and add the new profile in the persistent DataManager.
My first question is: is it ok to design a hook like this? From a general software design point of view, this code gives encapsulation, separation of concerns, and a correct state management. What I'm not sure about if this is proper in a functional world like React.
My second question is: why is my component (that uses useProfileData) not updated when addNewProfile is called? For example:
const ProfileSelector = (props) => {
const [ newProfileName, setNewProfileName ] = useState('');
const { profiles, currentProfile, setCurrentProfileID, addNewProfile } = useProfileData();
function createNewProfile() {
addNewProfile({
name: newProfileName,
});
}
return (
<div>
<ProfilesList profiles={profiles} onProfileClick={pid => setCurrentProfileID(pid)} />
<div>
<input type="text" value={newProfileName} onChange={e => setNewProfileName(e.target.value)} />
<Button label="New profile" onPress={() => createNewProfile()} />
</div>
</div>
);
};
ProfilesList and Button are components defined elsewhere.
When I click on the Button, a new profile is added to the persistent DataManager, but profiles is not updated, and ProfilesList isn't either (of course).
I'm either implementing something wrong, or this is not a paradigm that can work in React. What can I do?
EDIT
As suggested by #thedude, I tried using a reducer. Here is the (stub) of my reducer:
const ProfilesReducer = (state, action) => {
const newState = state;
switch (action.type) {
case 'addNewProfile':
{
const newProfile = action.newProfile;
newState.profiles.push(newProfile);
DataManager.addNewProfile(newProfile);
}
break;
default:
throw new Error('Unexpected action type: ' + action.type);
}
return newState;
}
After I invoke it (profilesDispatch({ type: 'addNewProfile', newProfile: { name: 'Test' } });), no change in profilesState.profiles is detected - or at least, a render is never triggered, nor an effect. However, the call to DataManager has done its job and the new profile has been persisted.
You should never mutate your state, not even in a reducer function.
From the docs:
If you return the same value from a Reducer Hook as the current state, React will bail out without rendering the children or firing effects. (React uses the Object.is comparison algorithm.)
Change your reducer to return a new object:
const ProfilesReducer = (state, action) => {
switch (action.type) {
case 'addNewProfile':
{
const newProfile = action.newProfile;
return {...state, profiles: [...state.profiles, newProfile]}
}
break;
default:
throw new Error('Unexpected action type: ' + action.type);
}
return state;
}
Also not that reducer should no have side effects, if you want to perform some action based on a state change, use a useEffect hook for that.
For example:
DataManager.addNewProfile(newProfile) should not be called from the reducer

Having to dynamically change the state : how to avoid an infinite-loop?

I have a React component which receives data from a parent.
Now, this data is dynamic and I do not know beforehand what the properties are called exactly.
I need to render them in a certain fashion and that all works perfectly fine.
Now though, these dynamic objects have a property which is a number, which has to be displayed in my component.
To do so, I thought while iterating over the data, I will add the values to the sum, which is to be displayed. Whenever one of the data object changes, the sum will change, too (since I am using useState and React will detect that change.
But that exactly is the problem I don't know how to solve.
It is obvious that right now my code generates an infinite-loop:
The component is created and rendered for the first time.
During this process, setSum() is called, and therefore changing the state.
React detects that and orders a re-rendering.
So how do I fix this? I feel like I am missing something quite obvious here, but I am too invested to see it.
I have tried to boil down my code to the most easy to read code snippet which focuses on the problem only. Any suggestions to improve the readabilty are welcome!
const ComponentA = (data) => {
const [sum, setSum] = React.useState(0);
const renderData = (dataToRender) => {
//Here lies the problem already
setSum(0)
const result = [];
dataToRender.forEach((objData, index) => {
result.push(<JSX Item>Content</JSX Item>);
//and some more stuff, not relevant
// will not get this far
const newSum = sum+objData.propertyAmount;
setSum(newSum);
});
return result;
};
return(
//...someJSXElements
{data.relevantObjectArray && renderData(data.relevantObjectArray)}
<div>{sum}</div>
);
}
The reason it's re-rendering infinitely is because you are setting the state every time the component is rendered, which subsequently triggers another re-render. What you need to do is separate your display code from your state-setting code. I initially thought that useEffect would be a good solution (you can see the edit history for my original answer), however from the React docs:
useReducer is usually preferable to useState when you have complex state logic that involves multiple sub-values or when the next state depends on the previous one. (React docs)
So you could therefore try something like this:
const reducer = (sum, action) => {
switch (action.type) {
case "increment":
return sum + action.propertyAmount;
default:
throw new Error();
}
};
const initialSum = 0;
const ComponentA = (data) => {
const [sum, sumReducer] = React.useReducer(reducer, initialSum);
React.useEffect(() => {
data.relevantObjectArray.forEach((objData, index) => {
sumReducer({ type: "increment", propertyAmount: objData.propertyAmount });
});
}, [data.relevantObjectArray]);
const renderData = (dataToRender) => {
const result = [];
dataToRender.forEach((objData, index) => {
result.push(<div>Content</div>);
});
return result;
};
return (
<div>
{data.relevantObjectArray && renderData(data.relevantObjectArray)}
<div>{sum}</div>
</div>
);
};
Example on codesandbox.io.
const ComponentA = (data) => {
const [sum, setSum] = React.useState(0);
const renderData = (dataToRender) => {
//Here lies the problem already
setSum(0)
const result = [];
dataToRender.forEach((objData, index) => {
result.push(<JSX Item>Content</JSX Item>);
//and some more stuff, not relevant
// will not get this far
const newSum = sum+objData.propertyAmount;
setSum(newSum);
});
return result;
};
React.useEffect(() => {
renderData();
return () => {
console.log('UseEffect cleanup')});
}, [data);
return(
//...someJSXElements
//The line below is causing the continuos re-render because you keep calling the function (renderData)
//{data.relevantObjectArray && renderData(data.relevantObjectArray)}
<div>{sum}</div>
);
}

What is the best way to mutate remote data fetched with useQuery

I am fairly new to graphQL and Apollo. I hope I can make myself clear:
I am fetching data using the apollo/react-hook useQuery. Afterwards I populate a form with the data so the client can change it. When he is done, the data gets send back to the server using useMutation.
Until now, I use onCompleted to store the fetched data in the component state. That looks like this:
import React, { useState } from 'react';
import { TextField } from '#material-ui/core';
const Index = () => {
const [state, setState] = useState(null)
const {data, loading, error} = useQuery<typeof queryType>(query, {
onCompleted: data => {
// modify data slightly
setState(data)
}
})
return (
<TextField value={state} onChange={() => setState(event.target.value)}/>
)
}
The form than uses the values stored in the component state and the form handlers use setState
to change it.
My question now is, if this is the best practice and if the storing of the fetched data in a local component state neccessary.
Because you don't want to just fetch and render the data -- you want to be able to mutate it when the form values change -- we can't just utilize the data as is. Unless you're using uncontrolled inputs and refs to manage your forms (which you probably shouldn't do), then you're going to need to use some component state.
I think your current approach is mostly fine. The biggest downside is that if the query takes a while (maybe the user has a spotty internet connection), there's a window for them to start filling out the form only to have their input overridden once the query completes. You'd have to explicitly disable the inputs or hide the form until loading completes in order to prevent this scenario.
An alternative approach is to split your component into two:
const Outer = () => {
const {data, loading, error} = useQuery(query)
if (!data) {
return null // or a loading indicator, etc.
}
return <Inner data={data}/>
}
const Inner = ({ data }) => {
const [value, setValue] = useState(data.someField)
<TextField value={value} onChange={() => setValue(event.target.value)}/>
}
By not rendering of the inner component until loading is complete, we ensure that the initial props we pass to it have the data from the query. We can then use those props to initialize our state.
Seems like useQuery only has a state for responseId: https://github.com/trojanowski/react-apollo-hooks/blob/master/src/useQuery.ts
But you get a refetch function from the useQuery as well.
const { loading, error, data, refetch } = useQuery(...);
Try calling refetch(), after you used useMutation() to update the data. It shouldn't trigger a rerender. You probably have to set your own state for that reason.
Maybe something like:
const handleUpdate = () =>{
setData(refetch());
}
Alternativly you get the data after using useMutation which is also in the state: https://github.com/trojanowski/react-apollo-hooks/blob/master/src/useMutation.ts
const [update, { data }] = useMutation(UPDATE_DATA);
data will always be the newest value so you could also do:
useEffect(()=>{
setData(data);
// OR
// setData(refetch());
}, [data])

Resources