I'm struggling to implement full support of infinite scroll with cursor pagination, adding new elements and removing. I have used great example from github discussion https://github.com/reduxjs/redux-toolkit/discussions/1163#discussioncomment-876186 with few adjustments based on my requirements. Here is my implementation of useInfiniteQuery.
export function useInfiniteQuery<
ListOptions,
ResourceType,
ResultType extends IList<ResourceType> = IList<ResourceType>,
Endpoint extends QueryHooks<
QueryDefinition<any, any, any, ResultType, any>
> = QueryHooks<QueryDefinition<any, any, any, ResultType, any>>,
>(
endpoint: Endpoint,
{
options,
getNextPageParam,
select = defaultSelect,
inverseAppend = false,
}: UseInfiniteQueryOptions<ListOptions, ResultType, ResourceType>,
) {
const nextPageRef = useRef<string | undefined>(undefined);
const resetRef = useRef(true);
const [pages, setPages] = useState<ResourceType[] | undefined>(undefined);
const [trigger, result] = endpoint.useLazyQuery();
const next = useCallback(() => {
if (nextPageRef.current !== undefined) {
trigger(
{ options: { ...options, page_after: nextPageRef.current } },
true,
);
}
}, [trigger, options]);
useEffect(() => {
resetRef.current = true;
trigger({ options }, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, Object.values(options));
useEffect(() => {
if (!result.isSuccess) {
return;
}
nextPageRef.current = getNextPageParam(result.data);
if (resetRef.current) {
resetRef.current = false;
setPages(select(result.data));
} else {
setPages((pages) =>
inverseAppend
? select(result.data).concat(pages ?? [])
: (pages ?? []).concat(select(result.data)),
);
}
}, [result.data, inverseAppend, getNextPageParam, select, result.isSuccess]);
return {
...result,
data: pages,
isLoading: result.isFetching && pages === undefined,
hasMore: nextPageRef.current !== undefined,
next,
};
}
This example works great with pagination, but the problem when I'm trying to add new elements. I can't distinguish are new elements arrived from new pagination batch or when I called mutation and invalidated tag (in this case RTK refetch current subscriptions and update result.data which I'm listening in second useEffect).
I need to somehow to identify when I need to append new arrived data (in case of next pagination) or replace fully (in case when mutation was called and needs to reset cursor and scroll to top/bottom).
My calling mutation and fetching the data placed in different components. I tried to use fixedCacheKey to listening when mutation was called and needs to reset data, but I very quickly faced with a lot of duplication and problem when I need to reuse mutation in different places, but keep the same fixed cache key.
Someone has an idea how to accomplish it? Perhaps, I need to take another direction with useInfiniteQuery implementation or provide some fixes to current implementation. But I have no idea to handle this situation. Thanks!
Related
I've been following along the REDUX essentials guide and I'm at part 8, combining RTK Query with the createEntityAdapter. I'm using the guide to implement it in a personal project where my getUni endpoint has an argument named country, as you can see from the code snippet below.
I'm wondering is there anyway to access the country argument value from the state in universityAdaptor.getSelector(state => ) at the bottom of the snippet, as the query key name keeps changing.
import {
createEntityAdapter,
createSelector,
nanoid
} from "#reduxjs/toolkit";
import {
apiSlice
} from "../api/apiSlice";
const universityAdapter = createEntityAdapter({})
const initialState = universityAdapter.getInitialState();
export const extendedApiSlice = apiSlice.injectEndpoints({
endpoints: builder => ({
getUni: builder.query({
query: country => ({
url: `http://universities.hipolabs.com/search?country=${country}`,
}),
transformResponse: responseData => {
let resConvert = responseData.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(each => {
return { ...each,
id: nanoid()
}
});
return universityAdapter.setAll(initialState, resConvert)
}
})
})
});
export const {
useGetUniQuery
} = extendedApiSlice;
export const {
selectAll: getAllUniversity
} = universityAdapter.getSelectors(state => {
return Object.keys({ ...state.api.queries[<DYNAMIC_QUERY_NAME>]data }).length === 0
? initialState : { ...state.api.queries[<DYNAMIC_QUERY_NAME>]data }
})
UPDATE: I got it working with a turnery operator due to the multiple redux Actions created when RTK Query handles fetching. Wondering if this is best practice as I still haven't figured out how to access the country argument.
export const { selectAll: getAllUniversity } = universityAdapter
.getSelectors(state => {
return !Object.values(state.api.queries)[0]
? initialState : Object.values(state.api.queries)[0].status !== 'fulfilled'
? initialState : Object.values(state.api.queries)[0].data
})
I wrote that "Essentials" tutorial :)
I'm actually a bit confused what your question is - can you clarify what specifically you're trying to do?
That said, I'll try to offer some hopefully relevant info.
First, you don't need to manually call someEndpoint.select() most of the time - instead, call const { data } = useGetThingQuery("someArg"), and RTKQ will fetch and return it. You only need to call someEndpoint.select() if you're manually constructing a selector for use elsewhere.
Second, if you are manually trying to construct a selector, keep in mind that the point of someEndpoint.select() is to construct "a selector that gives you back the entire cache entry for that cache key". What you usually want from that cache entry is just the received value, which is stored as cacheEntry.data, and in this case that will contain the normalized { ids : [], entities: {} } lookup table you returned from transformResponse().
Notionally, you might be able to do something like this:
const selectNormalizedPokemonData = someApi.endpoints.getAllPokemon.select();
// These selectors expect the entity state as an arg,
// not the entire Redux root state:
// https://redux-toolkit.js.org/api/createEntityAdapter#selector-functions
const localizedPokemonSelectors = pokemonAdapter.getSelectors();
const selectPokemonEntryById = createSelector(
selectNormalizedPokemonData ,
(state, pokemonId) => pokemonId,
(pokemonData, pokemonId) => {
return localizedPokemonSelectors.selectById(pokemonData, pokemonId);
}
)
Some more info that may help see what's happening with the code in the Essentials tutorial, background - getLists endpoint takes 1 parameter, select in the service:
export const getListsResult = (state: RootState) => {
return state.tribeId ? extendedApi.endpoints.getLists.select(state.tribeId) : [];
};
And my selector in the slice:
export const selectAllLists = createSelector(getListsResult, (listsResult) => {
console.log('inside of selectAllLists selector = ', listsResult);
return listsResult.data;
// return useSelector(listsResult) ?? [];
});
Now this console logs listsResult as ƒ memoized() { function! Not something that can have .data property as tutorial suggests. Additionally return useSelector(listsResult) - makes it work, by executing the memoized function.
This is how far I got, but from what I understand, the code in the Essentials tutorial does not work as it is...
However going here https://codesandbox.io/s/distracted-chandrasekhar-r4mcn1?file=/src/features/users/usersSlice.js and adding same console log:
const selectUsersData = createSelector(selectUsersResult, (usersResult) => {
console.log("usersResult", usersResult);
return usersResult.data;
});
Shows it is not returning a memorised function, but an object with data on it instead.
Wonder if the difference happening because I have a parameter on my endpoint...
select returns a memoized curry function. Thus, call it with first with corresponding arg aka tribeId in your case and then with state. This will give you the result object back for corresponding chained selectors.
export const getListsResult = (state: RootState) => {
return state.tribeId ? extendedApi.endpoints.getLists.select(state.tribeId)(state) : [];
};
The intention of the getUni endpoint was to produce an array of university data. To implement the .getSelector function to retrieve that array, I looped over all query values, searching for a getUni query and ensuring they were fulfilled. The bottom turnery operator confirms the getUni endpoint was fired at least once otherwise, it returns the initialState value.
export const { selectAll: getAllUniversity } = universityAdapter
.getSelectors(state => {
let newObj = {};
for (const value of Object.values(state.api.queries)) {
if (value?.endpointName === 'getUni' && value?.status === 'fulfilled') {
newObj = value.data;
}
}
return !Object.values(newObj)[0] ? initialState : newObj;
})
In a React + Redux frontend I'm currently experimenting with integrating HATEOAS into the overall process. The application initially starts without any knowledge other than the base URI the backend can be found at and the backend will add URIs to each resource the frontend is requesting.
The application itself currently is able to upload an archive file that contains files the backend should process based on some configuration the frontend is passing to the backend via multipart/form-data upload. Once the upload finished, the backend will create a new task for the upload and start processing the upload which results in some values being calculated that end up in a database where a controller in the backend is responsible for exposing various resources for certain files processed.
I currently use ReduxJS toolkit and slices combined with async thunks to manage and access the Redux store. The store itself has an own section dedicated for stuff returned by the backend API, i.e. the global links section, the page information and so forth and will store the result of each invokation with a slight remapping.
One of the challenges I faced here is that on initially requesting the component responsible for rendering all tasks, this component first needs to lookup the link for a predefined link-relation name in the backend API and on consecutive calls should reuse the available information. Here I came up with an action creator function like this:
type HalLinks = { [rel: string]: HalLink };
const requestLinks = async (uri: string, state: ApiState): Promise<[HalLinks, APILinkResponse | undefined]> => {
let links: APILinkResponse | undefined;
let rels: { [rel: string]: HalLink; };
if (!state.links || Object.keys(state.links).length === 0) {
console.debug(`Requesting links from ${uri}`);
const linkLookup = await axios(uri, requestConfig);
links = linkLookup.data;
if (links) {
rels = links._links;
} else {
throw new Error('Cannot resolve links in response');
}
} else {
links = undefined;
rels = state.links;
}
return [rels, links];
}
const lookupUriForRelName = (links: HalLinks, relName: string): HalLink | undefined => {
if (links) {
if (relName in links) {
return links[relName];
} else {
return undefined;
}
} else {
return undefined;
}
}
const requestResource = async (link: HalLink, pageOptions?: PageOptions, filterOptions?: FilterOptions) => {
let href: string;
if (link.templated) {
const templated: URITemplate = utpl(link.href);
href = fillTemplateParameters(templated, pageOptions, filterOptions);
} else {
href = link.href;
}
console.debug(`Attempting to request URI ${href}`)
const response = await axios.get(href, requestConfig);
return response.data;
}
const lookup = async <T> (state: ApiState, uri: string, relName: string, pageOptions?: PageOptions, filterOptions?: FilterOptions): Promise<[T, APILinkResponse | undefined]> => {
const [rels, links] = await requestLinks(uri, state);
const link: HalLink | undefined = lookupUriForRelName(rels, relName);
if (link) {
const data = await requestResource(link, pageOptions, filterOptions);
return [data, links];
} else {
throw new Error('No link relation for relation name ' + relName + ' found');
}
}
export const requestTasksAsync = createAsyncThunk<[APITasksResponse, APILinkResponse | undefined], { uri: string; pageOptions?: PageOptions; filterOptions?: FilterOptions; }>(
'api/tasks',
async ({ uri, pageOptions, filterOptions }: { uri: string, pageOptions?: PageOptions, filterOptions?: FilterOptions }, { getState }) => {
const state: ApiState = getState() as ApiState;
const TASK_REL = 'http://acme.com/rel/tasks'; // RFC 8288 compliant link relation extension according to section 2.1.2
return await lookup<APITasksResponse>(state, uri, TASK_REL, pageOptions, filterOptions);
}
);
In the code above I basically lookup up the links returned from the resource identified by the initial URI in case they are not present in the Redux store or in the state yet or are to old. In case we collected the links before this step is skipped and instead the information available within the state/store is reused (all happening in the requestLinks(...) function). Once the links are available we can finally request the resource that serves the tasks information we want to obtain.
The reducer for the action creator above looks like this:
export const apiSlice = createSlice({
name: 'api',
initialState,
reducers: {
...
},
extraReducers: (builder) => {
builder
...
.addCase(requestTasksAsync.fulfilled, (state, action) => {
const parts: [APITasksResponse, APILinkResponse | undefined] = action.payload;
const tasks: APITasksResponse = parts[0];
const linksResponse: APILinkResponse | undefined = parts[1];
// update any link information received in preceeding calls ot
// the performed actin
if (linksResponse) {
processsLinks(linksResponse._links, state);
}
processsLinks(tasks._links, state);
processPage(tasks.page, state);
processTasks(tasks._embedded.tasks, state);
state.status = StateTypes.SUCCESS;
})
...
}
});
where basically the two HTTP responses are taken and processed. In case we already retrieved those URIs before we don't have to lookup those again and as such the links response is undefined which we simply filter out with an if-statement.
The respective process functions are just helper functions to reduce the code in the reducer. I.e. the processTasks function just adds the task for the given taskId to the record present in the state:
const processTasks = (tasks: TaskType[], state: ApiState) => {
for (let i = 0; i < tasks.length; i++) {
let task: TaskType = tasks[i];
state.tasks[task.taskId] = task;
}
}
while links are separated into global URIs and component ones based on whether a custom link relation is used or one specified by IANA (i.e. up, next, prev, ...)
const processsLinks = (links: { [rel: string]: HalLink }, state: ApiState) => {
if (links) {
Object.keys(links).forEach(rel => {
if (rel.startsWith('http')) {
if (!state.links[rel]) {
console.debug(`rel ${rel} not yet present in state.links. Adding it with URI ${links[rel]}`);
state.links[rel] = links[rel];
} else {
console.debug(`rel ${rel} already present in state with value ${state.links[rel]}. Not going to add value ${links[rel]}`);
}
} else {
if (state.current) {
console.debug(`Updateing rel ${rel} for current component to point to URI ${links[rel]}`);
state.current.links[rel] = links[rel];
} else {
state.current = { links: { [rel]: links[rel] } };
console.debug(`Created new object for current component and assigned it the link for relation ${rel} with uri ${links[rel]}`);
}
}
});
}
}
Within the TaskOverview component the action is basically dispatched with the code below:
const [pageOptions, setPageOptions] = useState<PageOptions | undefined>();
const [filterOptions, setFilterOptions] = useState<TaskFilterOptions | undefined>()
const state: StateTypes = useAppSelector(selectState);
const current = useAppSelector(selectCurrent);
const tasks: Record<string, TaskType> = useAppSelector(selectTasks);
const dispatch = useAppDispatch();
useEffect(() => {
...
// request new tasks if we either have not tasks yet or options changed and we
// are not currently loading them
if (StateTypes.IDLE === state) {
// lookup tasks
dispatch(requestTasksAsync({uri: "http://localhost:8080/api", pageOptions: pageOptions, filterOptions: filterOptions}));
}
...
}, [dispatch, state, tasks, pageOptions, filterOptions])
The code above works. I'm able to lookup the URI based on the link relation and get the correct URI to retrieve the data exposed by the tasks resource and store those information into the Redux store. However, this all feels extremely clunky as I have to return two response objects from the action creation as I'm neither allowed to issue a dispatch from within a non-functional component nor alter the state within an action itself.
Ideally I'd love to dispatch actions in some way from within an async thunk and have a callback that informs me once the data is available in the store but I guess this is not possible. As I'm still fairly new to React/Redux I wonder if there is somehow a better approach available to trigger actions based on certain dependencies and in the absence of those load those dependencies before? I'm aware though that I could simply split the actions up into separated ones and then do the invocations within the respective component, though it feels like it will a) drag some of the state management logic the slice is responsible for into the respective component and b) duplicate some code.
You can totally dispatch from within an asyncThunk.
export const requestTasksAsync = createAsyncThunk<[APITasksResponse, APILinkResponse | undefined], { uri: string; pageOptions?: PageOptions; filterOptions?: FilterOptions; }, { state: ApiState }>(
'api/tasks',
async ({ uri, pageOptions, filterOptions }, { getState, dispatch }) => {
const state: ApiState = getState();
const TASK_REL = 'http://acme.com/rel/tasks'; // RFC 8288 compliant link relation extension according to section 2.1.2
dispatch(whatever())
return await lookup<APITasksResponse>(state, uri, TASK_REL, pageOptions, filterOptions);
}
);
Also, you don't need to repeat the arg type both in the generic and the payload generator function.
In your case, either use the generic (and then also move the ApiState type up into the generic), or skip the generic definition at the top and type everything inline.
I created a hook to use a confirm dialog, this hook provides the properties to the component to use them like this:
const { setIsDialogOpen, dialogProps } = useConfirmDialog({
title: "Are you sure you want to delete this group?",
text: "This process is not reversible.",
buttons: {
confirm: {
onPress: onDeleteGroup,
},
},
width: "360px",
});
<ConfirmDialog {...dialogProps} />
This works fine, but also I want to give the option to change these properties whenever is needed without declaring extra states in the component where is used and in order to achieve this what I did was to save these properties in a state inside the hook and this way provide another function to change them if needed before showing the dialog:
interface IState {
isDialogOpen: boolean;
dialogProps: TDialogProps;
}
export const useConfirmDialog = (props?: TDialogProps) => {
const [state, setState] = useState<IState>({
isDialogOpen: false,
dialogProps: {
...props,
},
});
const setIsDialogOpen = (isOpen = true) => {
setState((prevState) => ({
...prevState,
isDialogOpen: isOpen,
}));
};
// Change dialog props optionally before showing it
const showConfirmDialog = (dialogProps?: TDialogProps) => {
if (dialogProps) {
const updatedProps = { ...state.dialogProps, ...dialogProps };
setState((prevState) => ({
...prevState,
dialogProps: updatedProps,
}));
}
setIsDialogOpen(true);
};
return {
setIsDialogOpen,
showConfirmDialog,
dialogProps: {
isOpen: state.isDialogOpen,
onClose: () => setIsDialogOpen(false),
...state.dialogProps,
},
};
};
But the problem here is the following:
Arguments are passed by reference so if I pass a function to the button (i.e onDeleteGroup) i will keep the function updated to its latest state to perform the correct deletion if a group id changes inside of it.
But as I'm saving the properties inside a state the reference is lost and now I only have the function with the state which it was declared at the beginning.
I tried to add an useEffect to update the hook state when arguments change but this is causing an infinite re render:
useEffect(() => {
setState((prevState) => ({
...prevState,
dialogProps: props || {},
}));
}, [props]);
I know I can call showConfirmDialog and pass the function to update the state with the latest function state but I'm looking for a way to just call the hook, declare the props and not touch the dialog props if isn't needed.
Any answer is welcome, thank you for reading.
You should really consider not doing this, this is not a good coding pattern, this unnecessarily complicates your hook and can cause hard to debug problems. Also this goes against the "single source of truth" principle. I mean a situation like the following
const Component = ({title}: {title?: string}) => {
const {showConfirmDialog} = useConfirmDialog({
title,
// ...
})
useEffect(() => {
// Here you expect the title to be "title"
if(something) showConfirmDialog()
}, [])
useEffect(() => {
// Here you expect the title to be "Foo bar?"
if(somethingElse) showConfirmDialog({title: 'Foo bar?'})
}, [])
// But if the second dialog is opened, then the first, the title will be
// "Foo bar?" in both cases
}
So please think twice before implementing this, sometimes it's better to write a little more code but it will save you a lot debugging.
As for the answer, I would store the props in a ref and update them on every render somehow like this
/** Assign properties from obj2 to obj1 that are not already equal */
const assignChanged = <T extends Record<string, unknown>>(obj1: T, obj2: Partial<T>, deleteExcess = true): T => {
if(obj1 === obj2) return obj1
const result = {...obj1}
Object.keys(obj2).forEach(key => {
if(obj1[key] !== obj2[key]) {
result[key] = obj2[key]
}
})
if(deleteExcess) {
// Remove properties that are not present on obj2 but present on obj1
Object.keys(obj1).forEach(key => {
if(!obj2.hasOwnProperty(key)) delete result[key]
})
}
return result
}
const useConfirmDialog = (props) => {
const localProps = useRef(props)
localProps.current = assignChanged(localProps.current, props)
const showConfirmDialog = (changedProps?: Partial<TDialogProps>) => {
localProps.current = assignChanged(localProps.current, changedProps, false)
// ...
}
// ...
}
This is in case you have some optional properties in TDialogProps and you want to accept Partial properties in showConfirmDialog. If this is not the case, you could simplify the logic a little by removing this deleteExcess part.
You see that it greatly complicates your code, and adds a performance overhead (although it's insignificant, considering you only have 4-5 fields in your dialog props), so I really recommend against doing this and just letting the caller of useConfirmDialog have its own state that it can change. Or maybe you could remove props from useConfirmDialog in the first place and force the user to always pass them to showConfirmDialog, although in this case this hook becomes kinda useless. Maybe you don't need this hook at all, if it only contains the logic that you have actually shown in the answer? It seems like pretty much the only thing it does is setting isDialogOpen to true/false. Whatever, it's your choice, but I think it's not the best idea
I have this simple component that checks if username is valid. It does so by querying firebase when the input value changes. There is one problem with it. When I am typing too fast into the input field, the value in it just doesn't have enough time to change, so it just misses some characters. Here is the code:
For state management I am using Recoil.JS.
Component code:
export const UsernameInput = (props: {
topLabel: string;
bottomLabel?: string;
placeholder?: string;
className?: string;
valueIn: any;
valueOut: any;
valid: any;
validIn: boolean;
}) => {
const usernameRef = db.collection("usernames");
const query = usernameRef.where("username", "==", props.valueIn);
useEffect(() => {
query
.get()
.then((querySnapshot) => {
if (querySnapshot.size >= 1) {
props.valid(false);
} else {
props.valid(true);
}
})
}, [props.valueIn]);
function handleChange(event: any) {
props.valueOut(event.target.value);
}
return (
<InputSkeleton
topLabel={props.topLabel}
bottomLabel={props.bottomLabel}
className={props.className}
>
<div className="input-username">
<input type="text" onChange={handleChange} value={props.valueIn} />
<span className="text">
<span className={props.validIn ? "available" : "taken"}></span>
{props.validIn ? "Available" : "Taken"}
</span>
</div>
</InputSkeleton>
);
};
<UsernameInput
className="stretch"
topLabel="Username"
valueIn={formD.username}
valueOut={(value: string) => {
setFormD({ ...formD, username: value });
}}
valid={(value: boolean) => {
setFormD({ ...formD, usernameValid: value });
}}
validIn={formD.usernameValid}
bottomLabel="This will be your unique handle on xyz.com"
/>
Princewill's idea is the right one, but the implementation needs a little tweaking. Specifically, you need the timer handle to be preserved across multiple invocations of debounce, and the argument to debounce needs to be an actual function. Using a plain function doesn't do it, because each invocation results in a different local timeout handle, and the old handle never gets cancelled or updated.
I recommend adapting or using the useDebounce hook from useHooks. This uses useEffect to exploit React's effect unmounting to clear any previously-set timeouts, and is pretty clear overall.
const { valueIn, valueOut } = props;
const [username, setUsername] = useState<string>(valueIn);
// On each event, update `username`
const handleChange = useCallback(
(event: any) => setUsername(event.target.value),
[setUsername]
);
// Collect changes to username and change debouncedUsername to the latest
// value after a change has not been made for 500ms.
const debouncedUsername = useDebounce(username, 500);
// Each time debouncedUsername changes, run the desired callback
useEffect(() => {
if (debouncedUsername !== valueIn) {
valueOut(debouncedUsername);
}
}, [valueIn, valueOut, debouncedUsername]);
The idea here is:
You keep a realtime-updated copy of the field state via useState
You keep a delay-updated copy of the field state via useDebounce
When the delay-updated copy is finally changed, the useEffect fires your valueOut callback. As constructed, this would fire after username has changed, but has not changed again for 500ms.
Additionally, you would want to set your field's value to username, rather than valueIn, so that the field is updated in realtime, rather than on the delay.
Create a simple debounce function that takes a function and time in secs as parameters:
export function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
timeout = null;
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
Then use it in your event handler handleChange function:
function handleChange(event: any) {
event.preventDefault();
// This means that you want to update the value after 500 milliseconds, i.e when you're sure that the user has stopped typing. You can extend this time to whatever figure you want
debounce(props.valueOut(event.target.value), 500);
}
Put this variable outside UsernameInput function
const WAIT_INTERVAL = 1000;
Edit your handleChange to this
componentWillMount() {
this.timer = null;
}
function handleChange(event: any) {
clearTimeout(this.timer);
this.timer = setTimeout(props.valueOut(event.target.value), WAIT_INTERVAL);
}
I'm using custom hooks for a component, and the custom hook uses a custom context. Consider
/* assume FooContext has { state: FooState, dispatch: () => any } */
const useFoo = () => {
const { state, dispatch } = useContext(FooContextContext)
return {apiCallable : () => apiCall(state) }
}
const Foo = () => {
const { apiCallable } = useFoo()
return (
<Button onClick={apiCallable}/>
)
}
Lots of components will be making changes to FooState from other components (form inputs, etc.). It looks to me like Foo uses useFoo, which uses state from FooStateContext. Does this mean every change to FooContext will re-render the Foo component? It only needs to make use of state when someone clicks the button but never otherwise. Seems wasteful.
I was thinking useCallback is specifically for this, so I am thinking return {apiCallable : useCallback(() => apiCall(state)) } but then I need to add [state] as a second param of useCallback. Then that means the callback will be re-rendered whenever state updates, so I'm back at the same issue, right?
This is my first time doing custom hooks like this. Having real difficulty understanding useCallback. How do I accomplish what I want?
Edit Put another way, I have lots of components that will dispatch small changes to deeply nested properties of this state, but this particular component must send the entire state object via a RESTful API, but otherwise will never use the state. It's irrelevant for rendering this component completely. I want to make it so this component never renders even when I'm making changes constantly to the state via keypresses on inputs (for example).
Since you provided Typescript types in your question, I will use them in my response.
Way One: Split Your Context
Given a context of the following type:
type ItemContext = {
items: Item[];
addItem: (item: Item) => void;
removeItem: (index: number) => void;
}
You could split the context into two separate contexts with the following types:
type ItemContext = Item[];
type ItemActionContext = {
addItem: (item: Item) => void;
removeItem: (index: number) => void;
}
The providing component would then handle the interaction between these two contexts:
const ItemContextProvider = () => {
const [items, setItems] = useState([]);
const actions = useMemo(() => {
return {
addItem: (item: Item) => {
setItems(currentItems => [...currentItems, item]);
},
removeItem: (index: number) => {
setItems(currentItems => currentItems.filter((item, i) => index === i));
}
};
}, [setItems]);
return (
<ItemActionContext.Provider value={actions}>
<ItemContext.Provider value={items}>
{children}
</ItemContext.Provider>
</ItemActionContext.Provider>
)
};
This would allow you to get access to two different contexts that are part of one larger combined context.
The base ItemContext would update as items are added and removed causing rerenders for anything that was consuming it.
The assoicated ItemActionContext would never update (setState functions do not change for their lifetime) and would never directly cause a rerender for a consuming component.
Way Two: Some Version of an Subscription Based Value
If you make the value of your context never change (mutate instead of replace, HAS THE WORLD GONE CRAZY?!) you can set up a simple object that holds the data you need access to and minimises rerenders, kind of like a poor mans Redux (maybe it's just time to use Redux?).
If you make a class similar to the following:
type Subscription<T> = (val: T) => void;
type Unsubscribe = () => void;
class SubscribableValue<T> {
private subscriptions: Subscription<T>[] = [];
private value: T;
constructor(val: T) {
this.value = val;
this.get = this.get.bind(this);
this.set = this.set.bind(this);
this.subscribe = this.subscribe.bind(this);
}
public get(): T {
return this._val;
}
public set(val: T) {
if (this.value !== val) {
this.value = val;
this.subscriptions.forEach(s => {
s(val)
});
}
}
public subscribe(subscription: Subscription<T>): Unsubscriber {
this.subscriptions.push(subscription);
return () => {
this.subscriptions = this.subscriptions.filter(s => s !== subscription);
};
}
}
A context of the following type could then be created:
type ItemContext = SubscribableValue<Item[]>;
The providing component would look something similar to:
const ItemContextProvider = () => {
const subscribableValue = useMemo(() => new SubscribableValue<Item[]>([]), []);
return (
<ItemContext.Provider value={subscribableValue}>
{children}
</ItemContext.Provider>
)
};
You could then use some a custom hooks to access the value as needed:
// Get access to actions to add or remove an item.
const useItemContextActions = () => {
const subscribableValue = useContext(ItemContext);
const addItem = (item: Item) => subscribableValue.set([...subscribableValue.get(), item]);
const removeItem = (index: number) => subscribableValue.set(subscribableValue.get().filter((item, i) => i === index));
return {
addItem,
removeItem
}
}
type Selector = (items: Item[]) => any;
// get access to data stored in the subscribable value.
// can provide a selector which will check if the value has change each "set"
// action before updating the state.
const useItemContextValue = (selector: Selector) => {
const subscribableValue = useContext(ItemContext);
const selectorRef = useRef(selector ?? (items: Item[]) => items)
const [value, setValue] = useState(selectorRef.current(subscribableValue.get()));
const useEffect(() => {
const unsubscribe = subscribableValue.subscribe(items => {
const newValue = selectorRef.current(items);
if (newValue !== value) {
setValue(newValue);
}
})
return () => {
unsubscribe();
};
}, [value, selectorRef, setValue]);
return value;
}
This would allow you to reduce rerenders using selector functions (like an extremely basic version of React Redux's useSelector) as the subscribable value (root object) would never change reference for its lifetime.
The downside of this is that you have to manage the subscriptions and always use the set function to update the held value to ensure that the subscriptions will be notified.
Conclusion:
There are probably a number of other ways that different people would attack this problem and you will have to find one that suits your exact issue.
There are third party libraries (like Redux) that could also help you with this if your context / state requirements have a larger scope.
Does this mean every change to FooContext will re-render the Foo component?
Currently (v17), there is no bailout for Context API. Check my another answer for examples. So yes, it will always rerender on context change.
It only needs to make use of state when someone clicks the button but never otherwise. Seems wasteful.
Can be fixed by splitting context providers, see the same answer above for explanation.