I am still learning react and I currently have an issue where I am trying to create custom hooks and have it make different calls but rentain value of calls already made. Currently, the calls are made but previous values get set back to default which is not what I want.I want to be able to retain previous data. If this is not possible I would like to get that information or other approach to it.
export const createInitialState = <T, E = Error>(
initialData: T,
withPagination = false,
initialPaginationParams?: Partial<OffsetPaginationParams>
): ApiState<T, E> => ({
data: initialData,
loading: false,
error: null,
...(withPagination
? {
pagination: {
...DEFAULT_PAGINATION_PARAMS,
...initialPaginationParams,
},
}
: {}),
});
export function apiStateReducer<T, E = Error>(
state: ApiState<T, E>,
action: ApiAction<T, E>
): ApiState<T, E> {
switch (action.type) {
case "REQUEST":
return {
...state,
loading: true,
error: null,
};
case "SUCCESS":
const newData =
typeof action.payload === "function"
? (action.payload as (prev: T) => T)(state.data)
: action.payload;
return {
...state,
data: newData,
loading: false,
error: null,
};
case "FAILURE":
return {
...state,
loading: false,
error: action.error,
};
case "SET_PAGINATION_PARAMS":
if (!state.pagination) {
return state;
}
return {
...state,
pagination: {
...state.pagination,
...action.params,
},
};
case "RESET":
return createInitialState<T, E>(state.data, !!state.pagination);
default:
return state;
}
}
interface UseApiStateOptions {
withPagination?: boolean;
initialPaginationParams?: Partial<PaginationParams>;
}
export function useApiState<T, E = Error>(
initialData: T | null = null,
options: UseApiStateOptions = {}
) {
const [state, dispatch] = useReducer(
apiStateReducer<T, E>,
createInitialState<T, E>(
initialData,
options.withPagination,
options.initialPaginationParams
)
);
const setLoading = useCallback(() => {
dispatch({ type: "REQUEST" });
}, []);
const setData = useCallback((data: T) => {
dispatch({ type: "SUCCESS", payload: data });
}, []);
const setError = useCallback((error: E) => {
dispatch({ type: "FAILURE", error });
}, []);
const setPaginationParams = useCallback(
(params: Partial<PaginationParams>) => {
dispatch({ type: "SET_PAGINATION_PARAMS", params });
},
[]
);
const reset = useCallback(() => {
dispatch({ type: "RESET" });
}, []);
return {
state,
setLoading,
setData,
setError,
setPaginationParams,
reset,
};
}
Then in my customHook
interface TicketData {
tickets: Ticket[];
total: number;
selectedTicket: Ticket | null;
tasks: TicketTask[];
notes: TicketNote[];
conversations: TicketComment[];
activities: UserActivity[];
}
const initialData: TicketData = {
tickets: [],
total: 0,
selectedTicket: null,
tasks: [],
notes: [],
conversations: [],
activities: [],
};
export function useTickets() {
const { state, setLoading, setData, setError, setPaginationParams, reset } =
useApiState<TicketData>(initialData, {
withPagination: true,
initialPaginationParams: {
page: 1,
pageSize: 20,
},
});
console.log("Current state in useTickets:", state.data);
const fetchComments = useCallback(
async (ticketId: string) => {
console.log("Fetched comments PRE:", state.data);
try {
setLoading();
const result = await ticketService.listComments(ticketId);
console.log("Fetched comments:", result?.data);
setData({
...state.data!,
conversations: result?.data ?? [],
});
return result?.data ?? [];
} catch (error) {
console.error("Error fetching comments:", error);
setError(error as Error);
return [];
}
},
[setData, setError, setLoading]
);
const selectTicket = useCallback(
async (id: string) => {
try {
setLoading();
const ticket = await ticketService.getTicket(id);
console.log("Fetched ticket:", ticket);
setData({
...state.data!,
selectedTicket: ticket ?? null,
});
return ticket;
} catch (error) {
console.error("Error selecting ticket:", error);
setError(error as Error);
throw error;
}
},
[state.data]
);
return {
// State
...state.data,
isLoading: state.loading,
error: state.error,
paginationParams: state.pagination,
total: state.data.total,
// Operations
fetchComments,
selectTicket,
setPaginationParams,
reset,
};
}
Now in my Component
export const TicketDetailsPage = () => {
const { ticketId } = useParams();
const isInitialLoadRef = useRef(true);
const {
selectedTicket,
conversations = [],
activities = [],
tasks = [],
notes = [],
isLoading,
selectTicket,
fetchComments,
} = useTickets();
useEffect(() => {
const loadData = async () => {
if (!ticketId) return;
try {
console.log("Loading data for ticket:", ticketId);
// Load ticket first
await selectTicket(ticketId);
// Load all related data in parallel
await Promise.all([
fetchComments(ticketId),
]);
console.log("All data loaded");
} catch (error) {
console.error("Error loading ticket data:", error);
}
};
if (isInitialLoadRef.current && ticketId) {
loadData();
isInitialLoadRef.current = false;
}
}, [
ticketId,
selectTicket,
fetchComments,
]);
...
So now when selectTicket
gets called, I see the data but once fetchComments
gets called, it automatically sets selectedTicket
which should not be.
If you need more information kindly let me know. I appriciate every help possible.
Issue
The fetchComments
callback has a Javascript Closure issue, in that it closes over the un-updated state
value which is passed into the setData
success action and unconditionally overwrites whatever the current state is.
Trivially you could add all the missing hook dependencies to the useCallback
hook call.
const fetchComments = useCallback(
async (ticketId: string) => {
console.log("Fetched comments PRE:", state.data);
try {
setLoading();
const result = await ticketService.listComments(ticketId);
console.log("Fetched comments:", result?.data);
setData({
...state.data!,
conversations: result?.data ?? [],
});
return result?.data ?? [];
} catch (error) {
console.error("Error fetching comments:", error);
setError(error as Error);
return [];
}
},
[setData, setError, setLoading, state] // <-- add state as a dependency
);
But this only really pushes the Closure issue down stream. For example, now the useEffect
hook call in TicketDetailsPage
will get a new “copy” of the fetchComments
, but the currently in-flight loadData
fetching the data and dispatching actions to the useReducer
hook won’t ever see the updated copy.
Solution Suggestion
Don’t try to pass any current state along with any new values you are wanting to add to the state. Handle merging all new state into the current state in the reducer cases. This avoids the stale closure issue of trying to manage this outside the reducers.
case "SUCCESS":
const newData =
typeof action.payload === "function"
? (action.payload as (prev: T) => T)(state.data)
: action.payload;
return {
...state,
data: {
...state.data, // <-- shallow copy current data here!
...newData, // <-- shallow merge new data here
},
loading: false,
error: null,
};
Now selectTicket
and fetchComments
need only pass the new data in the action, removing state
as a useCallback
hook dependency.
const selectTicket = useCallback(
async (id: string) => {
try {
setLoading();
const ticket = await ticketService.getTicket(id);
console.log("Fetched ticket:", ticket);
setData({
selectedTicket: ticket ?? null,
});
return ticket;
} catch (error) {
console.error("Error selecting ticket:", error);
setError(error as Error);
throw error;
}
},
[setData, setError, setLoading]
);
const fetchComments = useCallback(
async (ticketId: string) => {
console.log("Fetched comments PRE:", state.data);
try {
setLoading();
const result = await ticketService.listComments(ticketId);
console.log("Fetched comments:", result?.data);
setData({
conversations: result?.data ?? [],
});
return result?.data ?? [];
} catch (error) {
console.error("Error fetching comments:", error);
setError(error as Error);
return [];
}
},
[setData, setError, setLoading]
);
Since it looks like you also typed your action payload to accept a callback function that is passed the current state.data
value I suspect you could also implement a functional state update as well. Something like the following:
setData(data => ({
...data,
selectedTicket: ticket ?? null,
}));
setData(data => ({
...state.data,
conversations: result?.data ?? [],
}));
Between the two though the first is preferable since it doesn’t rely on downstream consumers of the state and handlers to know and maintain any part of your state invariant, e.g. it’s shape & properties. Complete and sole responsibility for updating the state rests fully on the case reducers.
5