Hi,
We use an atom and a selector for providing a custom fetchWithAuth which automatically sends an authentication token for every request:
export const authTokenState = atom({
key: 'authTokenState',
default: '',
});
const fetchWithCredentials = async (
input: RequestInfo,
init?: RequestInit,
token?: string,
) => {
const response = await fetch(input, {
method: 'GET',
credentials: 'include',
...init,
headers: {
pragma: 'no-cache',
'cache-control': 'no-cache',
Accept: 'application/json, text/plain, */*',
...(token ? { Authorization: token } : null),
...(init ? init.headers : null),
},
});
const { status } = response;
// history previously from useHistory
//if (status === 401) history.replace(loginUrl);
return response;
};
export const fetchWithAuth = selector({
key: 'fetchWithAuthState',
get: ({ get }) => {
const authToken = get(authTokenState);
return (input: RequestInfo, init?: RequestInit) =>
fetchWithCredentials(input, init, authToken);
},
});
Previously, the fetchWithAuth has been provided by a custom hook, which used the useHistory hook to add the redirect in case of a 401.
I could add the history as a parameter and could use selectorFamily instead but then every consumer would have to provide the history every time it wants to use the custom fetchWithAuth function.
Now my question is: How can I access the history inside the selector properly?
Maybe I'm using it all in the completely wrong way, I'm at the beginning of digging into Recoil. Therefore, any suggestions are welcome.
@tklepzig I'm not sure how you're using fetchWithAuth in your app, but looking at the code above, you probably don't need a selector to create a fethWithAuth function. Why not use a custom hook?
export const authTokenState = atom({
key: "authTokenState",
default: "",
});
// this now does one thing only: making the request
const fetchWithCredentials = (
input: RequestInfo,
init?: RequestInit,
token?: string
) => {
return fetch(input, {
method: "GET",
credentials: "include",
...init,
headers: {
pragma: "no-cache",
"cache-control": "no-cache",
Accept: "application/json, text/plain, */*",
...(token ? { Authorization: token } : null),
...(init ? init.headers : null),
},
});
};
// instead of a using a selector, you can use a custom hook that encapsulates the business logic
export const useFetchWithAuth = () => {
const history = useHistory();
const authToken = useRecoilValue(authTokenState);
const fetchWithAuth = useCallback(
async (input: RequestInfo, init?: RequestInit) => {
const response = await fetchWithCredentials(input, init, authToken);
if (response.status === 401) {
history.replace(loginUrl);
}
return response;
},
[history, authToken]
);
return fetchWithAuth;
};
@wsmd Actually, it was a custom hook before. My intention was to have it as a selector for using it later in other selectors (e.g. fetching user data, etc.). When having it as a custom hook, I have the same problem with using a hook inside a selector.
Oh I see your point now! Perhaps you could maintain a reference to the history object in an atom so that other selectors can access it.
const historyState = atom({ key: 'history', default: null })
// mount this component under your application's root
function HistorySubscriber() {
const history = useHistory();
const setHistoryState = useSetRecoilState(historyState);
useEffect(() => setHistoryState(history), [history]);
}
function Root() {
return (
<RecoilRoot initializeState={initializeState}>
<HistorySubscriber />
<App />
</RecoilRoot>
)
}
const fetchWithAuth = selector({
key: 'fetchWithAuthState',
get: ({ get }) => {
const authToken = get(authTokenState);
// now you should be able to access the history object from within the selector
const history = get(historyState);
return async (input: RequestInfo, init?: RequestInit) => {
const response = await fetchWithCredentials(input, init, authToken);
if (response.status === 401) {
history.replace(loginUrl);
}
return response;
}
},
});
Great, thank you! Exactly what I was searching for 馃憤
There's still a small issue: The type of useHistory is History, so it's not nullable. With the introduced atom, I have the default value of null, so now the value of the history state is always History | null, which leads to checking for null and an else branch with no available history:
if (response.status === 401) {
if (history) {
history.replace(loginUrl);
}
else {
// ?
}
}
Is there any way to avoid the History | null type for the atom?
casting could help:
const historyState = atom({ key: 'history', default: null as unknown as History})
// or
const historyState = atom<History>({ key: 'history', default: null as any})
Indeed, that would skip the type check at design time, nevertheless at runtime on first render the history state is null since that's the default of this atom.
it's cause useEffect calls after the render so all actions performed before are under this case. I guess it still can be passed like
useMemo(() => setHistoryState(history , [history]) without assignment, but it looks like a hack.
or somehow handle history out of the recoil like common subscriber (will depends on your requirements)
const redirectState = selector({
key: 'key',
get: async ({get}) => {
const response = await get(stateWIthRequest);
const shouldRedirect = response.status === 401;
return {shouldRedirect, location: loginUrl} //or dynamic location based on derived states in dependent selectors/atoms
}
})
const HistorySubscriber() => {
const history = useHistory();
const state = useRecoilValue(redirectState);
useEffect(() => state.shouldRedirect && history.replace(state.location), [state]);
return null;
}
I learned that RecoilRoot can be used with an initial state, so I tried the following:
const historyState = atom({ key: 'history', default: null });
const App = () => {
const history = useHistory();
return <RecoilRoot initializeState={({ set }) => { set(historyState, history); }}>
...
</RecoilRoot>
}
It seems to work, on the very first render the history is already set. Are there any possible drawbacks using this approach?
I honestly missed that feature, and it looks good.
One point confuses me is to use mutable structure and mutate it inside the states which apriory should be "pure" at least during each call. If it works in your case why not.
Are you trying to avoid the typescript interface problem, or the problem itself that the history might be null when you try to use it?
@DacianCoder Actually both. I'd like to avoid any if (history) when accessing the history and of course Typescript should know that history can't be null or undefined.
If you create the history yourself, you can set it as the default value. We use browser history so this is our setup:
import React, { useEffect } from 'react';
import { createBrowserHistory } from 'history';
import { atom, useSetRecoilState } from 'recoil';
const browserHistory = createBrowserHistory();
export const s_history = atom({
key: 'history',
default: browserHistory,
})
export function HistoryRecoilSync() {
const set = useSetRecoilState(s_location);
useEffect(() => {
return browserHistory.listen((location, action) => {
// action = 'PUSH' | 'POP' | 'REPLACE';
set(location);
});
}, [set]);
return <></>;
}
And our root looks like this:
export default function Startup() {
return (
<RecoilRoot>
<AuthRecoilSync />
<HistoryRecoilSync />
<MediaQueryRecoilSync />
<Initialize>
<AppHost />
</Initialize>
</RecoilRoot>
);
}
Where the <Initialize/> component runs things that need to be set up async and display a loading indicator, before showing the children.
Each of the RecoilSync components are just empty components that use effects to sync recoil state with non-recoil state.
Most helpful comment
I learned that
RecoilRootcan be used with an initial state, so I tried the following:It seems to work, on the very first render the history is already set. Are there any possible drawbacks using this approach?