React Server Components in Expo: A Practical Guide

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:

alt rsc payload

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:

alt movie details screen

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:

alt ios films screen react bundle

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.

alt ios film screen rsc bundle and libraries

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

We want to work with you. Check out our Services page!