I'm writing an app that uses Apollo Client to make graphql requests against a MongoDB Realm database.
This is the abstract structure of it:
<MongoContext>
<ApolloContext>
<App/>
</ApolloContext>
</MongoContext>
The top level component handles user authentication and provides a context. The next component down initiates Apollo Client and the caching logic and sets the context to the whole app.
The expected data flow is shown in the diagram on this page. The default behavior for a useQuery is that Apollo:
Tries to fetch from cache;
Tries to fetch from server; If successful, saves to cache.
My goal is to achieve offline capability. So, referring to the diagram again, the very first query should be resolved by the cache when it holds the data. Apollo's default cache mechanism is in memory, so I'm using apollo-cache-persist to cache it to localStorage.
More specifically, these are the conditions required:
The app should be responsive upon start.
At first render, the app is either offline or hasn't authenticated yet
Therefore it must read from cache, if available
If there's no cache, don't make any requests to the server (they'd all fail)
If the user is online, the app should get the authentication token for the requests
The token is requested asynchronously
While the token is unknown, read from the cache only (As 1.2 above)
When the token is known, use the data flow described above
My main problems are specifically with 1.2 and 2.2 above. I.e. preventing Apollo from making requests to the server when we already know it will fail.
I was also looking for a global solution, so modifying individual queries with skip or useLazyQuery aren't options. (And I'm not even sure that would work - I still needed the queries to be executed against the cache.)
Code:
ApolloContext component:
import * as React from 'react';
import {
ApolloClient,
InMemoryCache,
ApolloProvider,
createHttpLink,
NormalizedCacheObject,
} from '#apollo/client';
import { setContext } from '#apollo/client/link/context';
import { persistCache } from 'apollo-cache-persist';
import { PersistentStorage } from 'apollo-cache-persist/types';
const ApolloContext: React.FC = ({ children }) => {
// this hook gets me the token asynchronously
// token is '' initially but eventually resolves... or not
const { token } = useToken();
const cache = new InMemoryCache();
const [client, setClient] = React.useState(createApolloClient(token, cache))
// first initialize the client without the token, then again upon receiving it
React.useEffect(() => {
const initPersistCache = async () => {
await persistCache({
cache,
storage: capacitorStorageMethods,
debug: true,
});
};
const initApollo = async () => {
await initPersistCache();
setClient(createApolloClient(token, cache));
};
if (token) {
initApollo();
} else {
initPersistCache();
}
}, [token]);
console.log('id user', id, user);
return <ApolloProvider client={client}>{children}</ApolloProvider>;
};
function createApolloClient(
token: string,
cache: InMemoryCache
) {
const graphql_url = `https://realm.mongodb.com/api/client/v2.0/app/${realmAppId}/graphql`;
const httpLink = createHttpLink({
uri: graphql_url,
});
const authorizationHeaderLink = setContext(async (_, { headers }) => {
return {
headers: {
...headers,
Authorization: `Bearer ${token}`,
},
};
});
return new ApolloClient({
link: authorizationHeaderLink.concat(httpLink),
cache,
});
}
What I've tried:
After attempting many different things. I found something that works, but it looks terrible. The trick is to give Apollo a custom fetch that rejects all requests when the user is not logged in:
const customFetch = (input: RequestInfo, init?: RequestInit | undefined) => {
return user.isLoggedIn
? fetch(input, init)
: Promise.reject(new Response());
};
const httpLink = createHttpLink({
uri: graphql_url,
fetch: customFetch,
});
Another way to prevent outbound requests is to just omit the link property:
return new ApolloClient({
link: user.isLoggedIn
? authorizationHeaderLink.concat(httpLink)
: undefined,
cache,
});
}
That looks way cleaner but now the problem is that make queries that can't be fulfilled by the cache to hang on loading forever.(related issue)
I'm looking for a cleaner and safer way to do this.
Related
I recently started using react-query and have encountered the issue that always stale data is returned and no call to server is made. here is the react query related code:
export function useGetAccount(id: number){
return useQuery([`account${id}`, id], async (args) => {
const [key, accountId] = args.queryKey
const [acc, teams, modules] = await Promise.all([
getAccount(),
getTeams(),
getModules()])
let account: AccountDetail = {
accountId: acc.accountId,
userId: acc.userId,
companyId: acc.companyId,
login: acc.login,
email: acc.email,
description: acc.description,
isActive: acc.isActive,
providers: acc.providers,
teams: teams,
modules: modules
}
return account
async function getAccount() {
const api = createApi() // <= axios wrapper
const { data } = await api.get(`accounts/${accountId}`, undefined, undefined)
return data as AccountModel
}
async function getTeams() {
const api = createApi()
const { data } = await api.get(`accounts/${accountId}/teams`, undefined, undefined)
const { collection } = data as ResponseCollectionType<AccountTeam>
return collection
}
async function getModules() {
const api = createApi()
const { data } = await api.get(`accounts/${accountId}/resources`, undefined, undefined)
const { collection } = data as ResponseCollectionType<ModuleAccessModel>
return collection
}
})
}
I even reduced the cache time but still to no avail. I do not see any calls made to server side except after a long delay or if I open the browser in incognito mode then first time the data is fetched and then no call is made.
this is used in a component which shows the details and is passed the id as a prop. everything is working fine except that the data is the one which was retrieved first time and even a refresh (F5) returns the stale data.
what changes do I need to make in this case?
[observation]: Ok, it does make a call but only after exact 5 minutes.
well the problem is not in react-query but in axios, described here Using JavaScript Axios/Fetch. Can you disable browser cache?
I used the same solution i.e. appending timestamp to the requests made by axios and everything worked fine.
I'm playing around with reactQuery in a little demo app you can see in this repo. The app calls this mock API.
I'm stuck on a an issue where I'm using the useQuery hook to call this function in a product API file:
export const getAllProducts = async (): Promise<Product[]> => {
const productEndPoint = 'http://localhost:5000/api/product';
const { data } = await axios.get(productEndPoint);
return data as Array<Product>;
};
In my ProductTable component I then call this function using:
const { data } = useQuery('products', getAllProducts);
I'm finding the call to the API does get made, and the data is returned. but the table in the grid is always empty.
If I debug I'm seeing the data object returned by useQuery is undefined.
The web request does successfully complete and I can see the data being returned in the network tab under requests in the browser.
I'm suspecting its the way the getAllProducts is structured perhaps or an async await issue but can't quite figure it out.
Can anyone suggest where IO may be going wrong please?
Simply use like this
At first data is undefined so mapping undefined data gives you a error so we have to use isLoading and if isLoading is true we wont render or map data till then and after isLoading becomes false then we can render or return data.
export const getAllProducts = async (): Promise<Product[]> => {
const productEndPoint = 'http://localhost:5000/api/product';
const res= await axios.get(productEndPoint);
return res.data as Array<Product>;
};
const { data:products , isLoading } = useQuery('products', getAllProducts);
if(isLoading){
return <FallBackView />
}
return (){
products.map(item => item)
}
I have managed to get this working. For the benefits of others ill share my learnings:
I made a few small changes starting with my api function. Changing the function to the following:
export const getAllProducts = async (): Promise<Product[]> => {
const response = await axios.get(`api/product`, {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
});
return response.data as Product[];
};
I do not de-construct the response of the axios call but rather take the data object from it and return is as an Product[]
Then second thing I then changed was in my ProductTable component. Here I told useQuery which type of response to expect by changing the call to :
const { data } = useQuery<Product[], Error>('products', getAllProducts);
Lastly, a rookie mistake on my part: because I was using a mock api in a docker container running on localhost and calling it using http://localhost:5000/api/product I was getting the all to well known network error:
localhost has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present...
So to get around that for the purpose of this exercise I just added a property to the packages.json file: "proxy":"http://localhost:5000",
This has now successfully allowed fetching of the data as I would expect it.
I have an axios client, defined in a provider context and accessed throughout my app with a useAxios hook.
The provider uses an access token, which gets updated every few minutes using a token refresh. This is definitely updating correctly.
I use an interceptor to add the token to the request headers. But of course, I want the latest token. So, each time the token updates, I change the interceptor to use the new one. I do this in a useEffect which only fires if the token changes, and I have verified that this effect is firing correctly.
But, any use of the axios client subsequent to a token refresh still uses the original interceptors - the ones that were defined when the app was first loaded. Consequently, after the original token times out, all my requests are unauthenticated even though I've added an interceptor with an up to date token, because teh original interceptor is being used...
THE QUESTION
How do I correctly update this interceptor so the latest one is used on every query?
THE CODE
import React, { useEffect } from 'react'
import axios from 'axios'
import { useAuth } from './useAuth'
import { apiErrors } from 'errors'
export const AxiosContext = React.createContext(undefined)
const AxiosProvider = ({ children }) => {
const { accessToken } = useAuth()
const axiosClient = React.useMemo(() => {
return axios.create({
headers: {
'Content-Type': 'application/json',
},
})
}, [])
// Attach request interceptor to add the auth headers
useEffect(() => {
console.log(
'VERIFIED TO WORK - THIS SHOWS PERIODICALLY ON TOKEN UPDATE, WITH THE NEW TOKEN',
accessToken
)
axiosClient.interceptors.request.use((request) => {
if (accessToken) {
request.headers.Authorization = `JWT ${accessToken}`
}
return request
})
}, [axiosClient, accessToken])
return (
<AxiosContext.Provider value={axiosClient}>
{children}
</AxiosContext.Provider>
)
}
// A hook for accessing this authenticated axios instance
function useAxios() {
return React.useContext(AxiosContext)
}
export { AxiosProvider, useAxios }
I´m using JWT authentication inside my ReactJS RelayJS network environment. All the token retrieval and processing in server and client are fine. I´m using react router v4 for routing.
My problem is when I receive a Unauthorized message from server (status code 401). This happens if the user points to an application page after the token has expired, ie. What I need to do is to redirect to login page. This is the code I wish I could have:
import { Environment, Network, RecordSource, Store } from 'relay-runtime';
const SERVER = 'http://localhost:3000/graphql';
const source = new RecordSource();
const store = new Store(source);
function fetchQuery(operation, variables, cacheConfig, uploadables) {
const token = localStorage.getItem('jwtToken');
return fetch(SERVER, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + token,
Accept: 'application/json',
'Content-type': 'application/json'
},
body: JSON.stringify({
query: operation.text, // GraphQL text from input
variables
})
})
.then(response => {
// If not authorized, then move to default route
if (response.status === 401)
this.props.history.push('/login') <<=== THIS IS NOT POSSIBLE AS THERE IS NO this.history.push CONTEXT AT THIS POINT
else return response.json();
})
.catch(error => {
throw new Error(
'(environment): Error while fetching server data. Error: ' + error
);
});
}
const network = Network.create(fetchQuery);
const handlerProvider = null;
const environment = new Environment({
handlerProvider, // Can omit.
network,
store
});
export default environment;
Naturally calling this.props.history.push is not possible as the network environment is not a ReactJS component and therefore has no properties associated.
I´ve tried to throw an error at this point, like:
if (response.status === 401)
throw new Error('Unauthorized');
but I saw the error on the browser console, and this cannot be treated properly in the code.
All I wanna do is to redirect to login page in case of 401 error received, but I can´t find a proper way of doing it.
I am not using relay but a render prop. I experienced kind of the same issue. I was able to solve it using the window object.
if (response.statusText === "Unauthorized") {
window.location = `${window.location.protocol}//${window.location.host}/login`;
} else {
return response.json();
}
You can go with useEnvironment custom hook.
export const useEnvironment = () => {
const history = useHistory(); // <--- Any hook using context works here
const fetchQuery = (operation, variables) => {
return fetch(".../graphql", {...})
.then(response => {
//...
// history.push('/login');
//...
})
.catch(...);
};
return new Environment({
network: Network.create(fetchQuery),
store: new Store(new RecordSource())
});
};
// ... later in the code
const environment = useEnvironment();
Or you can create HOC or render-prop component if you are using class-components.
btw: this way you can also avoid usage of the localStorage which is slowing down performance.
I've been researching this for the past couple hours, and I am now extremely close to solving this. Everything seems to be working now, but I do not have this.props.mutate in my component that has the Apollo HOC wrapping it.
I wired Apollo up as a reducer, so in my component, I would expect to see this.props.apollo.mutate available, but it's not there.
This is all that seems to be provided currently:
console.log(this.props.apollo)
{"data":{},"optimistic":[],"reducerError":null}
Here is how it is hooked up:
./src/apolloClient.js
import { AsyncStorage } from 'react-native'
import { ApolloClient, createNetworkInterface } from 'react-apollo'
const networkInterface = createNetworkInterface({
uri: 'http://localhost:8000/graphql',
opts: {
credentials: 'same-origin',
mode: 'cors'
}
})
// networkInterface is half baked as I haven't got this far yet
networkInterface.use([{
async applyMiddleware(req, next) {
if (!req.options.headers) req.options.headers = {}
const token = await AsyncStorage.getItem('token')
req.options.headers.authorization = token ? token : null
next()
}
}])
const client = new ApolloClient({
networkInterface,
dataIdFromObject: (o) => o.id
})
export default client
./src/reducers.js
import client from './apolloClient'
export default combineReducers({
apollo: client.reducer(),
nav: navReducer,
signup: signupReducer,
auth: loginReducer,
})
./src/App.js
import store from './store'
import client from './apolloClient'
const Root = () => {
return (
<ApolloProvider store={store} client={client}>
<RootNavigationStack />
</ApolloProvider>
)
}
export default Root
Oh, and here's the bottom of my Login component (also fairly half-baked):
export default compose(
connect(mapStateToProps, {
initiateLogin,
toggleRememberMe
}),
withFormik({
validate,
validateOnBlur: true,
validateOnChange: true,
handleSubmit: ({ tel, password }, { props }) => {
props.apollo.mutate({
variables: { tel, password }
})
.then((res) => {
alert(JSON.stringify(res))
//const token = res.data.login_mutation.token
//this.props.signinUser(token)
//client.resetStore()
})
.catch((err) => {
alert(JSON.stringify(err))
//this.props.authError(err)
})
//props.initiateLogin({ tel, password })
}
}),
graphql(LOGIN_MUTATION, { options: { fetchPolicy: 'network-only' } })
)(LoginForm)
It feels like I need an action creator and to manually map it to my component. What do I need to do to run this loosely shown mutation LOGIN_MUTATION onSubmit?
I'm currently confused by the fact this.props.apollo has Apollo's data in it, but there is no mutate.
I don't see the solution here: http://dev.apollodata.com/react/mutations.html or maybe I do -- is this what I need to be looking at?
const NewEntryWithData = graphql(submitRepository, {
props: ({ mutate }) => ({
submit: (repoFullName) => mutate({ variables: { repoFullName } }),
}),
})(NewEntry)
I'd like to get it to the point where the component can call the mutation when it needs to. I'd also like it to be available on this.props.something so I can call it from Formik's handleSubmit function, but I am open to suggestions that enable the best declarative scalability.
[edit] Here is the code that I am considering solved:
./src/apolloClient.js
This file was scrapped.
./src/reducers.js
I removed the Apollo reducer and client reference.
./src/App.js
I put the Apollo Client inside the root component. I got this technique from Nader Dabit's Medium post. He illustrates this in a GitHub repo:
https://github.com/react-native-training/apollo-graphql-mongodb-react-native
https://medium.com/react-native-training/react-native-with-apollo-part-2-apollo-client-8b4ad4915cf5
Here is how it looks implemented:
const Root = () => {
const networkInterface = createNetworkInterface({
uri: 'http://localhost:8000/graphql',
opts: {
credentials: 'same-origin',
mode: 'cors'
}
})
networkInterface.use([{
async applyMiddleware(req, next) {
try {
if (!req.options.headers) req.options.headers = {}
const token = await AsyncStorage.getItem('token')
req.options.headers.authorization = token || null
next()
} catch (error) {
next()
}
}
}])
const client = new ApolloClient({
networkInterface,
dataIdFromObject: (o) => o.id
})
return (
<ApolloProvider store={store} client={client}>
<RootNavigationStack />
</ApolloProvider>
)
}
export default Root
When you use compose, the order of your HOCs matters. In your code, the props added by your first HOC (connect) are available to all the HOCs after it (withFormik and graphql). The props added by withFormik are only available to graphql. The props added by graphql are available to neither of the other two HOCs (just the component itself).
If you rearrange the order to be compose -> graphql -> withFormik then you should have access to props.mutate inside withFormik.
Additionally, while you can integrate Redux and Apollo, all this does is prevent you from having two separate stores. Passing an existing store to Apollo is not going to change the API for the graphql HOC. That means, regardless of what store you're using, when you correctly use the HOC, you will still get a data prop (or a mutate prop for mutations).
While integrating Apollo with Redux does expose Apollo's store to your application, you should still use Apollo like normal. In most cases, that means using the graphql HOC and utilizing data.props and data.mutate (or whatever you call those props if you pass in a name through options).
If you need to call the Apollo client directly, then use withApollo instead -- this exposes a client prop that you can then use. The apollo prop that connect exposes in your code is just the store used by Apollo -- it's not the actual client, so it will not have methods like mutate available to it. In most cases, though, there's no reason to go with withApollo over graphql.