Reflecting on Code Sharing Between React and React Native
June 9, 2023 - 6 min read
intermediate
react
react native
react query
typescript
One of my favorite parts of the Javascript ecosystem is the opportunity for sharing code
between different types of deployments: Web, Native, Desktop, Backend. Here are some
reflections on building a moderately sized application using React and React Native.
Don't try to share UI code
There are efforts in the React/React Native community to write components once
and render them natively and for the web, ie: React Native Web.
When thinking about responsive web components in React, it is already a lot of overhead to
make a website that looks good at mobile/tablet/desktop breakpoints. Considerations for UI
and layout on desktop are completely different from a mobile experience. As such, it is not
incredibly useful to try to have a single UI that looks great on web and mobile. I found
that as long as I am able to reuse most of my non-UI code, I could compose UIs very quickly
while also having the freedom to make applications that look great on desktop and native.
Here is a list of non-UI code that I found incredibly useful to share:
- Non-UI components
- Request hooks (React Query)
- Schemas for validation (Zod)
- Stores (Zustand)
- Typescript types
- Utils (String/Date formatting, Generic functions)
Requests will be different
For my project, I used React Query extensively. If you are
unfamiliar with React Query, it's a hook based library for making requests, and it has a ton of
configuration for caching data, refetching, background actions, and much more.
Caching requests
In the web and native versions of the application, I preferred a Query Client configuration that fetched
any requests for a page every time that page loaded. This ensured that when a user viewed any
page, they always had the most up to date information. My Query Client setup looked like this:
new QueryClient({ defaultOptions: { queries: { retry: false, // refetch requests when mounting (page loads for the first time) refetchOnMount: true, refetchOnWindowFocus: false, // keep the fetched requests forever in cache to prevent unnecessary refetching staleTime: Infinity, }, }, });
This setup essentially says that requests should be fetched once per page load, and then cached forever.
If I needed to make an update to cached data, it was very easy to call
client.invalidateQueries
to
force a request to be refetched. This worked great for having dependent data be updated after a successful
POST request.Refetching for native
Many native applications use a "pull to refresh" pattern for updating the content in the current view.
React Query made it very easy to expose a
refetch
function as part of the request hooks that I could
call from React Native's ScrollView
"refreshControl" prop.<ScrollView refreshControl={ <RefreshControl refreshing={isRefetching} onRefresh={refetch} /> } > {/* your content */} </ScrollView>
This
refetch
functionality remained unused in the web version of the application, but since
it is just one more property to expose from React Query's useQuery
hook, it made it really
easy to use the refetch functionality when I needed it in the native application.Use a Monorepo
I used npm workspaces to create
a pretty simple package structure that looked like this:
/packages /native /shared /web
The "native" package contained the React Native/Expo codebase. The "web" package contained
the Nextjs codebase. The "shared" package contained all of the code that was shared between
web and native. However, I let the web and native applications handle the compilation/transpilation
of the shared package, without having to jump through the hoops of needing to build the shared
package in a way that it was compatible with web and native targets.
Web (Nextjs)
Using the transpilePackages
configuration, it was very easy to have the shared package be built in exactly the same
way that the rest of the web application was built.
// next.config.js module.exports = { reactStrictMode: true, swcMinify: true, transpilePackages: ['shared'], };
Native (React Native/Expo)
React Native with Expo/Metro was a bit more tricky, but by using the
extraNodeModules
property
in the Expo/Metro configuration I was able to add the "shared" package as an additional
node modules directory so that it would get compiled/transpiled with the rest of the React
Native application.Use Context/Providers for Equivalent Functionality With Different Implementations
In React Native, interacting with native storage is asynchronous using the Async Storage library. This is in contrast to using
Session/Local Storage or cookies in a web environment, which is always synchronous.
In my project, I wanted to store a JWT token as a cookie for the web application so that
it was easily accessible on the frontend and backend, for cases where I wanted to use
the SSR features of Nextjs. Cookies aren't a thing in React Native, which prefers
having all storage set with the Async Storage API.
A solution to this problem is to create shared Context/Providers that have the exact same
contract, but have different implementation details depending on the environment.
For example, I wrote an Auth Context/Provider that looked like this:
<AuthContext.Provider value={{ clearToken: clearAuthToken, setToken: setAuthToken, token, }} > {children} </AuthContext.Provider>
The Native and Web environments were able to specify their own implementation of those
functions. In the Native environment,
clearToken
, setToken
, and token
interact
with the Async Storage API. In the Web environment, those values are created by
interacting with a Web-specific Cookie library.With this Context that implemented a shared interface, it became really easy
to write request hooks that need to send a token as part of the API request
without needing to conditionally check if the request is being sent from a Native
or Web environment. The request hooks don't care where the token comes from,
they just need a token to be available to send when making API requests.
function useFetchData() { const { token } = useContext(AuthContext); // make an authenticated request }
Another example of this is environment variables. Just about every flavor of React
application (CRA, Nextjs, Vite, React Native, etc) handle environment variables
differently. Nextjs requires that environment variables be prefixed with
NEXT_PUBLIC_
.
Vite uses import.meta.env
.Having a Context/Provider to specify environment variable values allowed papering over
the subtle differences in how environment variables must be structured in different projects.
Nextjs
<EnvVarsContext.Provider value={{ BASE_URL: process.env.NEXT_PUBLIC_BASE_URL, }} > {children} </EnvVarsContext.Provider>
Vite
<EnvVarsContext.Provider value={{ BASE_URL: import.meta.env.BASE_URL, }} > {children} </EnvVarsContext.Provider>
React Native
<EnvVarsContext.Provider value={{ BASE_URL: process.env.BASE_URL, }} > {children} </EnvVarsContext.Provider>
In shared code
function SharedComponent() { const { BASE_URL } = useContext(EnvVarsContext); }
Conclusion
- Non UI Code is easy to share
- UI Code is so different for Native and Web that it is worth it to write separately
- Monorepo makes it easy to segment buckets of code
- Use Context to define common interfaces that have different implementation details