I’ve seen a lot of traction towards the development and adoption of React Server Components (RSCs) with new frameworks like React Router and Expo having first-class support. As intriguing as it might sound, they’re now not as simple to digest as any new tool or technique out there. However, if you’re not aware, that’s fine. We’ll explore how server components (or server functions) work within the context of Expo and Expo Router.
RSCs nature
Server Components in Expo introduce a new paradigm for building React Native apps by allowing you to run certain components on the server. This means you gain direct access to external resources, such as querying SQL databases, the filesystem, performing heavy computations, or accessing server-only resources directly within the body of your components, and then stream the rendered result to the client.
- Client components work in the same way you’ve seen components in the frontend before. They’re identified by the
'use client'
directive and can run on both client and server. Most libraries and tools will work in the same way as before. - Server components, when writing a server component, are limited to running things on the server, thus having no access to browser or native APIs, as well as React client features such as built-in hooks, state, or the context API. Libraries and frameworks do not work out of the box, usually needing some sort of instrumentation to allow running RSCs first.
RSCs can interop with client components by, for example, having one parent component be a server component and a child being a client component with the ‘use client’. Surprisingly enough, this is not the case in the opposite direction: client components need to run on the client as well, so having server code inside them wouldn’t work out.
The boundaries are set when you start using the directives 'use server'
for Server Components and 'use client'
for Client components, or, as Dan Abramov likes to emphasize in his posts, it’s a door between two worlds.
This is the mental model.
RSC payload
RSC uses a structure similar to JSON to format React trees. Props that are passed to server components need to be serializable, meaning they must be able to be converted to a string and back without losing information.
Given the following component/function:
// @/functions/render-user.tsx
const textStyle: StyleProp<TextStyle> = { fontSize: 16 };
export const renderUser = async ({ id }) => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${id}`
);
const user = await response.json();
return <Text style={textStyle}>Name: {user?.name}</Text>;
};
Will make network requests for the RSC and output a payload similar to this:
1:I["node_modules/react-native/index.js", ...]
0:{
"_value":[
"$",
"$L1",
null,
{
"style":{"fontSize":16},
"children":["Name: ","Ervin Howell"] <---- Content injected by the server component
},
null
]
}
Suspendable components
One essential feature of server components is suspense boundaries. They are a declarative way to express asynchronous rendering in React. When you wrap a component with Suspense
, it allows you to specify a fallback UI that will be displayed while the component is loading.
In this case, the renderUser
function is suspended until the promise is resolved, or, in other words, when the data is fetched and the UI is rendered. It provides a fallback UI while waiting for the data to load.
import { Suspense } from "react";
import { Text } from "react-native";
import { renderUser } from "@/functions/render-user";
const User = () => {
return (
<Suspense fallback={<Text>Loading...</Text>}>
{renderUser({ id: "8" })}
</Suspense>
);
};
Suspense boundaries can also be nested. This allows you to create a more granular loading experience, where different parts of your UI can load independently.
Intrinsic changes
This is what you usually don’t see on posts, and it is less frequently seen on docs as well. I’m talking about the intrinsic changes that come with RSCs and the potential violations.
- “Event handlers cannot be passed to Client Component props. <… onPress={function onPress}>”: This is a common error when you try to pass an event handler from a server component to a client component. Remember functions are not serializable, so they can’t be passed down as props.
- “Client-only ‘useState’ API cannot be used in a React server component”: You can’t use hooks in the body of server components or in a file where ‘use server’ is present. This is because server components are not meant to be interactive and do not have access to the client-side React APIs.
- “Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with “use server”. Or maybe you meant to call this function rather than return it”: Also, a common mistake where you try to pass a function to a client component, but the function is not marked with “use server”.
I’m still trying to rewire my brain to understand these myself, but chances are, the framework you chose to work on already has a thoughtful way to inform these errors without burden.
It’s what makes RSCs rather unintuitive. The way you think about components and how they interact with each other is different. You have to think about the boundaries between client and server components, and how data flows between them.
Expo and Server Components
Now that we have a better understanding of RSCs, let’s focus on the potential benefits of adopting them on React Native.
You can see the full code for this application here
They’re opt-in; all react-native components are client components by default. React Server Components can have use cases that vary from data-fetching, document parsing, to processing authentication, video, and even AI.
Setting up
Create a new Expo project
npx create-expo-app@latest
Adding bundling support:
npm i react-server-dom-webpack@~19.0.0
Adding the new React Compiler (optional):
npx expo install babel-plugin-react-compiler@beta
Enabling RSCs (or server functions in Expo’s terms):
{
"expo": {
"experiments": {
"reactServerFunctions": true,
"reactCompiler": true,
}
}
}
Defining Server Functions
Let’s start by modifying the home screen to render an asynchronous list of movies, which we’ll declare soon.
import { Suspense } from "react";
import { StyleSheet, View } from "react-native";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import { renderFilms } from "@/functions/render-films";
import { FilmListSkeleton } from "@/components/FilmList";
export default function HomeScreen() {
return (
<SafeAreaProvider>
<SafeAreaView style={{ flex: 1 }}>
<View style={styles.container}>
<Suspense fallback={<FilmListSkeleton />}>{renderFilms()}</Suspense>
</View>
</SafeAreaView>
</SafeAreaProvider>
);
}
A Server component (or function) is as simple as an async function that does some operation and returns a React element.
// @/functions/render-films.tsx
"use server"; <--- here
import { FilmList } from "@/components/FilmList";
import { getGhibliFilms } from "@/server/http/get-film";
export const renderFilms = async () => {
const films = await getGhibliFilms();
return <FilmList films={films} />;
};
The FilmList
component is a client component that renders the list of films processed by the server function.
// @/components/FilmList.tsx
"use client"; <--- here
import { FlatList, View } from "react-native";
export const FilmList = ({ films }) => (
<FlatList
horizontal
data={films}
keyExtractor={(item) => item.id}
ItemSeparatorComponent={() => <View style={{ width: 10 }}></View>}
renderItem={({ item: film }) => <FilmCard film={film} />}
/>
);
// more code...
Server functions
You can use the server-only
package to enforce that certain modules or logic are only imported in server components, helping you avoid accidental leaks of sensitive code to the client.
import { cache } from "react";
import "server-only";
const ghibliApi = "https://ghibliapi.vercel.app/films/";
// @/server/http/get-film.ts
export const getGhibliFilms = cache(async () => {
const res = await fetch(ghibliApi);
if (res.status !== 200) throw new Error(`Status ${res.status}`);
return res.json();
});
One key change is that I’m using the newer cache
function from React to cache the result of the data fetching. This means that if multiple components request the same data, it will only trigger a single network request, improving both performance and battery life.
Processing data
Let’s define a screen for displaying the details of a particular movie.
// @/app/(tabs)/film/[id].tsx
import { Stack, useLocalSearchParams } from "expo-router";
import React from "react";
import { FilmContentSkeleton } from "@/components/FilmContentSkeleton";
import { renderFilm } from "@/functions/render-film";
export default function Film() {
const { id, name } = useLocalSearchParams<{ id: string; name: string }>();
return (
<>
<Stack.Screen options={{ title: name }} />
<React.Suspense fallback={<FilmContentSkeleton />}>
{renderFilm(id)}
</React.Suspense>
</>
);
}
Now if you look at the renderFilm
function, you’ll notice two interesting things:
"use server";
import { marked } from "marked";
import { getReviewsById } from "@/server/database/get-reviews"; <--- here
import { getGhibliFilmById } from "@/server/http/get-film";
export const renderFilm = async (id: string) => {
const film = await getGhibliFilmById(id);
const rows = await getReviewsById(id);
const reviews = rows.map((review) => ({
id: review.id,
username: review.username,
content: marked(review.content), <--- here
}));
return <FilmContent film={film} reviews={reviews} />;
};
The getReviewsById
function is a server function that queries a Postgres database using pg
library. We retrieve the reviews for that film and then parse the content (or comment) using marked
library, which allows us to convert Markdown content to HTML.
// @/server/database/get-reviews.ts
import "server-only";
export const getReviewsById = cache(async (id: string) => {
const { rows } = await client.query(
"SELECT * FROM reviews WHERE film_id = $1",
[id]
);
return rows;
});
With this, we have populated the reviews for the app:
Performance and App Bundle size
In mobile development, having a smaller bundle size likely increases your app’s user base. In Expo SDK 51+, the Expo team introduced a new way to analyze the bundle outputs of your app using the Expo Atlas tool.
Installing expo-atlas:
npm i -D expo-atlas
Running:
EXPO_UNSTABLE_ATLAS=true npx expo export
npx expo-atlas .expo/atlas.jsonl
Expo export is going to generate the JS and assets required for that app in all platforms using the Metro bundler alongside with atlas.jsonl
file that contains information about the bundle.
One of the key benefits of server components is the ability to reduce the amount of JavaScript sent to the client. Since server components never run on the device, their code is excluded from the client bundle, resulting in smaller app sizes and faster load times.
In the case of React Native, we can see which chunks of code are rendered on the server:
Going deeper, we can see which dependencies are included in the bundle of that server component. In this case, both marked
and pg
package and sub-dependencies are displayed in the overview.
Making sense of the unknown
I think you can probably see the potential of React Server Components in React Native now, but there are still some unknowns that we need to figure out. For example, how do we handle errors in server components? How do we manage state in server components? How do we test server components?
These are all valid questions, and I think the answers will come naturally with time as the React community advances to explore this new paradigm. It is important to remember that RSCs are still in their early stages, and it is by no means a complete solution or standard to follow for every type of project.
Good reads
- React Server Components
- Composition patterns
- Expo Router V5
- Universal RSCs
- What Does “use client” Do?
- Functional HTML
We want to work with you. Check out our Services page!