React data router - pass context to loader - javascript

I have a JWT-based API. It rotates the tokens on every response. I have a custom provider that manages this.
I'm trying to figure out how I would use React Router v6.4 data router with this setup. Specifically, I'd like to use the loader / action functions for getting the data, but those don't support useContext and I'm not sure how to pass that in.
I'd like dashboardLoader to call the API with the current set of tokens as headers that AuthContext is managing for me.
The goal is to have the loader function fetch some data to display on the dashboard and to use the get() call from the AuthProvider.
My current alternative is to just do it inside the Dashboard component but would like to see how to do this with a loader.
The relevant files:
// App.js
import "./App.css";
import { createBrowserRouter } from "react-router-dom";
import "./App.css";
import Dashboard, { loader as dashboardLoader } from "./dashboard";
import AuthProvider from "./AuthProvider";
import axios from "axios";
function newApiClient() {
return axios.create({
baseURL: "http://localhost:3000",
headers: {
"Content-Type": "application/json",
},
});
}
const api = newApiClient();
export const router = createBrowserRouter([
{
path: "/",
element: (<h1>Welcome</h1>),
},
{
path: "/dashboard",
element: (
<AuthProvider apiClient={api}>
<Dashboard />
</AuthProvider>
),
loader: dashboardLoader,
},
]);
// AuthProvider
import { createContext, useState } from "react";
const AuthContext = createContext({
login: (email, password) => {},
isLoggedIn: () => {},
get: async () => {},
post: async () => {},
});
export function AuthProvider(props) {
const [authData, setAuthData] = useState({
client: props.apiClient,
accessToken: "",
});
async function login(email, password, callback) {
try {
const reqData = { email: email, password: password };
await post("/auth/sign_in", reqData);
callback();
} catch (e) {
console.error(e);
throw e;
}
}
function isLoggedIn() {
return authData.accessToken === "";
}
async function updateTokens(headers) {
setAuthData((prev) => {
return {
...prev,
accessToken: headers["access-token"],
};
});
}
async function get(path) {
try {
const response = await authData.client.get(path, {
headers: { "access-token": authData.accessToken },
});
await updateTokens(response.headers);
return response;
} catch (error) {
console.error(error);
throw error;
}
}
async function post(path, data) {
try {
const response = await authData.client.post(path, data, {
headers: { "access-token": authData.accessToken },
});
await updateTokens(response.headers);
return response.data;
} catch (error) {
// TODO
console.error(error);
throw error;
}
}
const context = {
login: login,
isLoggedIn: isLoggedIn,
get: get,
post: post,
};
return (
<AuthContext.Provider value={context}>
{props.children}
</AuthContext.Provider>
);
}
// Dashboard
import { useContext } from "react";
import { Navigate } from "react-router-dom";
import AuthContext from "./AuthProvider";
export function loader() {
// TODO use auth context to call the API
// For example:
// const response = await auth.get("/my-data");
// return response.data;
}
export default function Dashboard() {
const auth = useContext(AuthContext);
if (!auth.isLoggedIn()) {
return <Navigate to="/" replace />;
}
return <h1>Dashboard Stuff</h1>;
}

Create the axios instance as you are, but you'll tweak the AuthProvider to add request and response interceptors to handle the token and header. You'll pass a reference to the apiClient to the dashboardLoader loader function as well.
AuthProvider
Store the access token in a React ref and directly consume/reference the passed apiClient instead of storing it in local component state (a React anti-pattern). Add a useEffect hook to add the request and response interceptors to maintain the accessTokenRef value.
export function AuthProvider({ apiClient }) {
const accessTokenRef = useRef();
useEffect(() => {
const requestInterceptor = apiClient.interceptors.request.use(
(config) => {
// Attach current access token ref value to outgoing request headers
config.headers["access-token"] = accessTokenRef.current;
return config;
},
);
const responseInterceptor = apiClient.interceptors.response.use(
(response) => {
// Cache new token from incoming response headers
accessTokenRef.current = response.headers["access-token"];
return response;
},
);
// Return cleanup function to remove interceptors if apiClient updates
return () => {
apiClient.interceptors.request.eject(requestInterceptor);
apiClient.interceptors.response.eject(responseInterceptor);
};
}, [apiClient]);
async function login(email, password, callback) {
try {
const reqData = { email, password };
await apiClient.post("/auth/sign_in", reqData);
callback();
} catch (e) {
console.error(e);
throw e;
}
}
function isLoggedIn() {
return accessTokenRef.current === "";
}
const context = {
login,
isLoggedIn,
get: apiClient.get,
post: apiClient.post,
};
return (
<AuthContext.Provider value={context}>
{props.children}
</AuthContext.Provider>
);
}
Dashboard
Note here that loader is a curried function, e.g. a function that consumes a single argument and returns another function. This is to consume and close over in callback scope the instance of the apiClient.
export const loader = (apiClient) => ({ params, request }) {
// Use passed apiClient to call the API
// For example:
// const response = await apiClient.get("/my-data");
// return response.data;
}
App.js
import { createBrowserRouter } from "react-router-dom";
import axios from "axios";
import "./App.css";
import Dashboard, { loader as dashboardLoader } from "./dashboard";
import AuthProvider from "./AuthProvider";
function newApiClient() {
return axios.create({
baseURL: "http://localhost:3000",
headers: {
"Content-Type": "application/json",
},
});
}
const apiClient = newApiClient();
export const router = createBrowserRouter([
{
path: "/",
element: (<h1>Welcome</h1>),
},
{
path: "/dashboard",
element: (
<AuthProvider apiClient={apiClient}> // <-- pass apiClient
<Dashboard />
</AuthProvider>
),
loader: dashboardLoader(apiClient), // <-- pass apiClient
},
]);

Related

How to refresh a token asynchronously using Apollo

I use postMessage to get the token from the mobile client. Js client sends request using requestRefresh function with postMessage to receive JWT token. Mobile clients execute JS code using a method call, which is described inside the WebView. I save the token in a cookie when I receive it in webview. I successfully get a token the first time I render a component, but I have a problem with updating the token asynchronously in the Apollo client when an "Unauthenticated" or "Invalid jwt token" error occurs.
I don't understand how I can call the call and response function from the mobile client asynchronously inside the onError handler and save the token.
I've reviewed several resources on this, StackOverflow answers 1, 2, Github examples 3, 4, and this blog post 5, but didn't find similar case
.
index.tsx
import React, { Suspense, useState, useEffect, useCallback, useMemo } from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import { ApolloClient, InMemoryCache, createHttpLink, ApolloProvider, from } from '#apollo/client';
import { setContext } from '#apollo/client/link/context';
import { onError } from '#apollo/client/link/error';
import { App } from 'app';
import Cookies from 'universal-cookie';
const cookies = new Cookies();
const httpLink = createHttpLink({
uri: process.env.API_HOST,
});
const authLink = setContext((_, { headers }) => {
const token = cookies.get('token');
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
});
const errorLink = onError(({ graphQLErrors, operation, forward }) => {
if (graphQLErrors)
for (const err of graphQLErrors) {
switch (err?.extensions?.code) {
case 'UNAUTHENTICATED':
// error code is set to UNAUTHENTICATED
//How to call and process a response from a mobile client here?
const oldHeaders = operation.getContext().headers;
operation.setContext({
headers: {
...oldHeaders,
authorization: token ? `Bearer ${token}` : '',
},
});
// retry the request, returning the new observable
return forward(operation);
}
}
});
const client = new ApolloClient({
cache: new InMemoryCache({
typePolicies: {
User: {
keyFields: ['userId'],
},
},
}),
link: from([errorLink, authLink, httpLink]),
uri: process.env.API_HOST,
connectToDevTools: process.env.NODE_ENV === 'development',
});
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import Cookies from 'universal-cookie';
interface IMessage {
action: string;
data?: {
text: string;
old_jwt?: string;
};
}
enum Actions {
refreshJWT = 'refresh_jwt',
}
interface IMessageEventData {
action: Actions;
success: boolean;
payload: {
data: string;
};
}
export const Index: React.FC = () => {
const cookies = new Cookies();
const token = cookies.get('token');
const message = useMemo((): IMessage => {
return {
action: Actions.refreshJWT,
data: {
text: 'Hello from JS',
...(token && { old_jwt: token }),
},
};
}, [token]);
const requestRefresh = useCallback(() => {
if (typeof Android !== 'undefined') {
Android.postMessage(JSON.stringify(message));
} else {
window?.webkit?.messageHandlers.iosHandler.postMessage(message);
}
}, [message]);
// #ts-ignore
window.callWebView = useCallback(
({ success, payload, action }: IMessageEventData) => {
if (success) {
if (action === Actions.refreshJWT) {
cookies.set('token', payload.data, { path: '/' });
}
} else {
throw new Error(payload.data);
}
},
[requestRefresh]
);
useEffect(() => {
requestRefresh();
}, []);
return (
<BrowserRouter>
<ApolloProvider client={client}>
<App />
</ApolloProvider>
</BrowserRouter>
);
};
ReactDOM.render(<Index />, document.getElementById('root'));

how to make useEffect without initial render

I just started learning javascript and react-redux, and I'm using useEffect to call POST method. So, I am wondering how to make it not send request to my backend whenever I open or refresh website
my HTTP Post looks like:
export const sendItemData = (items) => {
return async () => {
const sendRequest = async () => {
const response = await fetch("http://localhost:51044/api/Items", {
method: "POST",
body: JSON.stringify(items.Items),
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
credentials: "same-origin",
});
if (!response.ok) {
throw new Error("Sending data failed!");
}
};
try {
await sendRequest();
} catch (error) {
console.log(error);
}
};
};
and my App.js looks like:
import React from "react";
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import Items from "./components/Items ";
import { sendItemData } from "./store/items-actions";
function App() {
const dispatch = useDispatch();
const sendItems = useSelector((state) => state.items);
useEffect(() => {
dispatch(sendItemData(sendItems));
}, [sendItems, dispatch]);
return <Items />;
}
export default App;
Ok, this is my way, tray it
export function MyComp(props) {
const initial = useRef(true)
useEffect(() => {
if (!initial.current) {
// Yoyr code
} else {
// Mark for next times
initial.current = false;
}
}, [args]);
return (
<YourContent />
);
}

Mocking api request with jest and react js

I am testing api request call using jest and react testing library , here is my codes.
Live demo live demo
utils/api.js
import axios from "axios";
const instance = axios.create({
withCredentials: true,
baseURL: process.env.REACT_APP_API_URL,
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
});
export default instance;
export async function get(url, params) {
const response = await instance({
method: "GET",
url: url,
params: params,
});
return response;
}
mock/api.js
const mockResponse = {
data: {
attributes: {
first_name: "Jeff",
last_name: "Bezo's",
},
},
};
export default {
get: jest.fn().mockResolvedValue(mockResponse),
};
Home.js
import React, { useState, useEffect } from "react";
function Home() {
const [user, setUser] = useState();
const getUser = useCallback(async () => {
try {
const response = await api.get("/users/self");
console.log("data response..", response.data);
setUser(response.data.attributes.first_name);
} catch (error) {}
}, [dispatch]);
useEffect(() => {
getUser();
}, []);
return <div data-testid="user-info" > Welcome {user}</div>;
}
export default Home;
Home.test.js
import React, { Suspense } from "react";
import { render, cleanup, screen } from "#testing-library/react";
import "#testing-library/jest-dom/extend-expect";
import Home from "../../editor/Home";
import { Provider } from "react-redux";
import { store } from "../../app/store";
import Loader from "../../components/Loader/Loader";
const MockHomeComponent = () => {
return (
<Provider store={store}>
<Suspense fallback={<Loader />}>
<Home />
</Suspense>
</Provider>
);
};
describe("Home component", () => {
it("should return a user name", async () => {
render(<MockHomeComponent />);
const userDivElement = await screen.findByTestId(`user-info`);
expect(userDivElement).toBeInTheDocument();
screen.debug();
});
afterAll(cleanup);
});
Problem:
When I run npm run test test is passed but in the screen.debug() results I dont see the user name returned as expected I just see Welcome. but it should be welcome Jeff.
What am I doing wrong here? any suggestions will be appreciated

Redux thunk not returning function when using Apollo client

I'm using redux-thunk for async actions, and all was working as expected until I added an Apollo Client into the mix, and I can't figure out why. The action is being dispatched, but the return function is not being called.
-- Client provider so that I can use the client in the Redux store outside of the components wrapped in <ApolloProvider>.
import { ApolloClient, InMemoryCache, createHttpLink } from "#apollo/client";
class ApolloClientProvider {
constructor() {
this.client = new ApolloClient({
link: createHttpLink({ uri: process.env.gqlEndpoint }),
cache: new InMemoryCache(),
});
}
}
export default new ApolloClientProvider();
-- My store setup
const client = ApolloClientProvider.client;
const persistConfig = {
key: "root",
storage: storage,
};
const pReducer = persistReducer(persistConfig, rootReducer);
const store = createStore(
pReducer,
applyMiddleware(thunk.withExtraArgument(client))
);
-- The action
export const fetchMakeCache = (token) => (dispatch, client) => {
console.log("fetching");
const query = gql`
query Source {
sources {
UID
Name
ActiveRevDestination
}
workspaces {
UID
Name
SourceUids
ActiveRevSource
}
}
`;
return () => {
console.log("reached return");
dispatch(requestMakeCache());
client
.query({
query: query,
context: {
headers: {
Authorization: `Bearer ${token}`,
},
},
})
.then((r) => r.json())
.then((data) => dispatch(receivedMakeCache(data)))
.catch((error) => dispatch(failedMakeCache(error)));
};
};
-- The component dispatching the thunk
import React from "react";
import { useAuth0 } from "#auth0/auth0-react";
import { useDispatch } from "react-redux";
import * as actions from "../store/actions";
const Fetch = () => {
const dispatch = useDispatch();
const { getAccessTokenSilently, isAuthenticated } = useAuth0();
if (isAuthenticated) {
// This will set loading = false immediately
// The async function below results in loading not being set to false
// until other components are performing actions that will error
dispatch(actions.requestMakeCache());
(async () => {
const token = await getAccessTokenSilently({
audience: process.env.audience,
});
dispatch(actions.fetchMakeCache(await token));
})();
}
return <></>;
};
export default Fetch;
When the Fetch component loads, the "fetching" console log prints so it's definitely being dispatched. But the "reached return" never gets hit. This exact same code worked as expected when not using the Apollo client. However, I've been able to use the same client successfully in a component. I'm not getting any errors, the return function just isn't being hit.
Most of the questions on here about thunks not running the return function have to do with not dispatching correctly, but I don't think that's the case since this worked pre-Apollo. (Yes, I know that using redux and Apollo together isn't ideal, but this is what I have to do right now)

How to update ApolloClient authorization header after successful login?

Basically, we have this in our index.js file to set-up the ApolloProvider authorization to make queries / mutations.
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import ApolloClient from "apollo-boost";
import { ApolloProvider } from "react-apollo";
let session = localStorage.getItem("session");
let authorization = "";
if(session) {
let sessionObj = JSON.parse(session);
authorization = sessionObj.access_token
}
const graphQLServerURL = process.env.REACT_APP_API_URL;
const client = new ApolloClient({
uri: graphQLServerURL + "/graphql",
headers: {
authorization,
}
});
ReactDOM.render(
<ApolloProvider client={client}>
<App />
</ApolloProvider>
, document.getElementById('root'));
When the app first loads, the authorization header would be null. However, within the <App> component, we have a <Login> component which basically does a post request with a username and password. Upon successful request in the .then() method, we have:
.then(res => {
if (res === 200) {
localStorage.setItem("session", JSON.stringify(res.data));
history.push("/dashboard");
});
So what happens is the user is redirected to a <Dashboard> component which has a <Query> component (to list some data). However, the authorization in ApolloClient is still null until I hit refresh. Using push doesn't reload the <App> component (so that it gets the updated session from localstorage).
How should I do this in a way that after successful post request on login, the authorization from index.js gets the latest session object without having to reload the entire application?
You can use the request function if you use apollo-boost
const getToken = () => {
const token = localStorage.getItem('token');
return token ? `Bearer ${token}` : '';
};
const client = new ApolloClient({
uri: `${graphQLServerURL}/graphql`,
request: (operation) => {
operation.setContext({
headers: {
authorization: getToken(),
},
});
},
});
You will have to use a link. Ref
const httpLink = createHttpLink({
uri: '/graphql',
});
const authLink = setContext((_, { headers }) => {
// get the authentication token from local storage if it exists
const token = localStorage.getItem('token');
// return the headers to the context so httpLink can read them
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : "",
}
}
});
const client = new ApolloClient({
link: authLink.concat(httpLink),
});
I had a similar issue. I wanted to use the token into ApolloProvider. When the app was rendering for the first time I could not access the localStorage as a result the WebSocket could not work.
What I did?
I created a custom hook and used both useEffect and useState to update it.
import {
ApolloClient, InMemoryCache, HttpLink, split,
} from '#apollo/client';
import { WebSocketLink } from 'apollo-link-ws';
import { getMainDefinition } from 'apollo-utilities';
import { useEffect, useState } from 'react';
import config from './index';
export const useConfigClient = () => {
const [authTokenStorage, setAuthTokenStorage] = useState(localStorage.getItem('token')); // by default it is null
const cache = new InMemoryCache();
const httpLink = new HttpLink({
uri: config.GRAPHQL_URL,
headers: {
authorization: `Bearer ${authTokenStorage}`,
},
});
const wsLink = new WebSocketLink({
uri: config.GRAPHQL_WS,
options: {
reconnect: true,
connectionParams: {
headers: {
authorization: `Bearer ${authTokenStorage}`,
},
},
},
});
const link = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition'
&& definition.operation === 'subscription'
);
},
wsLink,
httpLink,
);
useEffect(() => {
setAuthTokenStorage(localStorage.getItem('token')); // as soon as it is available just update the token
}, []);
const client = new ApolloClient({
cache,
link,
});
return client;
};
How I used the custom hook?
import { ApolloProvider } from '#apollo/client';
import { useConfigClient } from '../config/apolloConfig'; // import the hooks
<ApolloProvider client={useConfigClient()}>
// ... add your component
</ ApolloProvider>
I am not sure it is the best way to fix it. For now, it works and I hope it will help others.
Another way to achieve this is that you can use setLink method from apollo-config.
After that, export a function that'll set the headers once you had your token.
import {ApolloClient, InMemoryCache, HttpLink} from '#apollo/client';
import {setContext} from '#apollo/client/link/context';
const httpLink = new HttpLink({uri: '/graphql'});
let config = new ApolloClient({
link: httpLink,
cache: new InMemoryCache(),
});
export function setGraphqlHeaders() {
const token = localStorage.getItem(AUTH_TOKEN);
const authLink = setContext((_, {headers}) => {
return {
headers: {
...headers,
authorization: token || null,
},
};
});
config.setLink(authLink.concat(httpLink));
}
Hope, this'll help someone
praveen-me's answer works for me in 2021 with Nextjs. I tried the ncabral's answer but I not sure it will automatically update the JWT for me.
On top of praveen-me's answer, I added something to the code for me:
userToken.ts
const userTokenKey = 'userToken'
export const getUserToken = () => {
try {
return localStorage.getItem(userTokenKey)
} catch (error) {
...
}
}
export const setUserToken = (token: string) => {
try {
localStorage.setItem(userTokenKey, token)
} catch (error) {
...
}
}
apolloClient.ts
import { ApolloClient, createHttpLink, InMemoryCache } from "#apollo/client";
import { setContext } from "#apollo/client/link/context";
import { getUserToken } from "./userToken";
const httpLink = createHttpLink({
uri: process.env.NEXT_PUBLIC_GRAPHQL_URL
})
let client = new ApolloClient({
// link: authLink.concat(httpLink),
link: httpLink,
cache: new InMemoryCache(),
});
export function updateGraphqlHeaders() {
const authLink = setContext((_, { headers }) => {
const token = getUserToken();
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : "",
}
}
})
client.setLink(authLink.concat(httpLink))
}
updateGraphqlHeaders() // Init
export default client;
_app.tsx
...
import { ApolloProvider } from "#apollo/client";
import client from "../auth/apolloClient";
function MyApp({ Component, pageProps }: AppProps) {
return (
<ApolloProvider client={client}>
<Component {...pageProps} />
</ApolloProvider>
);
}
export default MyApp;

Categories

Resources