15 avril 2025
Lynx: A Replacement for React Native?

9 minutes reading

Launched as an open source project several weeks ago, Lynx is a newcomer in the world of mobile development. Developed originally by ByteDance, the library is already being used in production in the TikTok application, similarly to how Facebook was using React Native within their mobile applications. Now available to the public, the question arises as to whether Lynx can become a competitor or even a replacement for React Native.
Technical Comparison
Flicking through the official documentation, it's easy to see that Lynx takes much inspiration from already existing frameworks, particularly from React Native and Flutter, to offer a development experience similar to that found in web applications:
- Modern bundler based on Rspack
- React Library based on React 17, builds bridges with native components
- Supports CSS stylesheets and the majority of CSS properties
- Supports selectors, whether for CSS or for runtime
In the same way as Expo Go, Lynx provides an application for installation on your simulator, Lynx Explorer, allowing you to run a bundle from a URL (either local or distant). This is why it's possible to run different examples provided in the documentation.
A DevTools application is also available with an element inspector (both HTML and native) and a JavaScript console. However, we do notice the absence of a network inspector, which is particularly appreciated in the Expo DevTools.
On the runtime side, PrimJS is used as the JavaScript engine, developed specifically for Lynx, just like React Native uses Hermes.
In terms of support, Lynx was designed to support mobile platforms as well as the web, in the same way as React Native supports Android, iOS (Windows, MacOS, Web also supported through third-party libraries).
Discovering Through Practice
To discover Lynx in more detail, let’s develop a simple two-screen application using the JSON Placeholder API, to list posts, access post details and list comments there.
Project Creation
Creating a Lynx project is quite simple:
yarn create rspeedy
After the project is created, all that's left is to install the dependencies:
yarn install
Then we launch our development server:
yarn dev
A URL will be generated. Just copy and paste this URL into the Lynx Explorer application, and voila!
Our First Screen
Let's get down to the nitty-gritty by erasing all rendering of the App
component, and let's create our post list:
import "./App.css";
import PostItem from "./components/PostItem.jsx";
import usePosts from "./hooks/usePosts.js";
export function App() {
const { data: posts, fetchNextPage } = usePosts();
return (
<list
className="safe-area posts-list"
list-type="single"
lower-threshold-item-count={2}
bindscrolltolower={() => fetchNextPage()}
>
{posts?.pages?.flat().map((post) => {
return (
<list-item key={post.id} item-key={post.id.toString()}>
<PostItem body={post.body} id={post.id} title={post.title} />
</list-item>
);
})}
</list>
);
}
type Props = {
body: string;
id: number;
title: string;
};
const PostItem = ({ body, id, title }: Props) => {
return (
<view className="post-item">
<text className="post-item-title">{title}</text>
<text className="post-item-content">{body}</text>
</view>
);
};
export default PostItem;
:root {
background-color: #fefefe;
--color-text: #000;
}
.safe-area {
padding: env(safe-area-inset-top) env(safe-area-inset-right)
env(safe-area-inset-bottom) env(safe-area-inset-left);
}
.posts-list {
width: 100%;
height: 100vh;
list-main-axis-gap: 16px;
padding-left: 16px;
padding-right: 16px;
}
.post-item {
display: flex;
flex-direction: column;
background-color: white;
border-radius: 12px;
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.2);
padding: 16px;
gap: 8px;
}
.post-item-title {
font-size: 16px;
font-weight: bold;
color: var(--color-text);
}
.post-item-content {
font-size: 14px;
color: var(--color-text);
}
import { useInfiniteQuery } from "@tanstack/react-query";
export type Post = {
id: number;
title: string;
body: string;
userId: string;
};
const usePosts = () => {
return useInfiniteQuery({
queryKey: ["posts"],
queryFn: async ({ pageParam }) => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?_page=${pageParam}&_limit=10`,
);
const data = await response.json();
return data as Post[];
},
getNextPageParam: (lastPage, allPages, lastPageParam) => {
if (lastPage.length === 0) return undefined;
return lastPageParam + 1;
},
initialPageParam: 1,
});
};
export default usePosts;
And there you have it, our list is ready. Several differences are already noticeable compared to React Native:
- No import of components (View, Text): they are available via the rendering engine (here the React library)
- Ability to use CSS classes. It's also possible to apply the style directly to a
style
prop, or to use theid
attribute, like in traditional HTML.
The list
element behaves similarly to React Native's FlatList. However, the concept of recycling views is entirely native here, where React Native implements it from the JS side, even though a number of alternative libraries attempt to resolve this issue (FlashList, Legend List, ShadowList). The API provided for the Lynx list is quite comprehensive, making the layout system highly modular. For instance, for multi-column lists, it is relatively easy to have a particular item take up all the available width. The documentation provides many examples showing different types of popular lists.
The most observant will have noted the bindscrolltolower
prop name, particularly the bind
prefix. Unlike React and React Native, where events tend to be prefixed by on
(for example onPress
), Lynx uses different prefixes depending on how you want to intercept an event.
Post Detail
Naturally, we now want to be able to click on a post to display its detail and its comments. We will therefore need a navigation system... which is non-existent! At least, this system is not provided by Lynx, which rather advises using React Router
version 6. So obviously it's not ideal: unlike React Navigation which provides components mapped to native screens (hence with native animations), we won't have any navigation animation, no native navigation component (header, tab bar, etc.), or even gesture interactivity with React Router. We will still use React Router
to manage navigation between pages.
import { root } from "@lynx-js/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, Route, Routes } from "react-router";
import { App } from "./App.js";
import Post from "./Post.jsx";
const queryClient = new QueryClient();
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Routes>
<Route index element={<App />} />
<Route path="/post/:id" element={<Post />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
if (import.meta.webpackHot) {
import.meta.webpackHot.accept();
}
import { useNavigate } from "react-router";
type Props = {
body: string;
id: number;
title: string;
};
const PostItem = ({ body, id, title }: Props) => {
const navigate = useNavigate();
const onTap = () => {
navigate(`/post/${id}`);
};
return (
<view className="post-item" bindtap={onTap}>
<text className="post-item-title">{title}</text>
<text className="post-item-content">{body}</text>
</view>
);
};
export default PostItem;
.post-container {
display: flex;
flex-direction: column;
gap: 16px;
padding-left: 16px;
padding-right: 16px;
}
.post-comment-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.post-comment-container:not(:last-of-type) {
margin-bottom: 16px;
}
.post-comment-title {
font-size: 18px;
font-weight: bold;
color: var(--color-text);
}
.post-comment-name {
font-size: 14px;
font-weight: 600;
color: var(--color-text);
}
.post-comment-content {
font-size: 14px;
color: var(--color-text);
}
import { useQuery } from "@tanstack/react-query";
import type { Post } from "./usePosts.js";
type PostWithComments = Post & {
comments: Comment[];
};
type Comment = {
id: string;
postId: string;
name: string;
email: string;
body: string;
};
const usePost = (id: number) => {
return useQuery({
queryKey: ["post", { id }],
queryFn: async () => {
const post = await fetch(
`https://jsonplaceholder.typicode.com/posts/${id}?_embed=comments`,
).then((response) => response.json());
return post as PostWithComments;
},
});
};
export default usePost;
A Bit of Animation?
A key component of mobile applications, animations bring our screens to life. In React Native, although an animation API is available, we tend to use the library Reanimated, which in its latest versions has launched experimental support for CSS animations. Well, Lynx supports this by default, whether it's through CSS stylesheets or imperatively through a function called at runtime.
Let's add a CSS animation to our post list:
@keyframes bounce {
0% {
transform: scale(1);
}
50% {
transform: scale(1.02);
}
100% {
transform: scale(1);
}
}
.post-item {
display: flex;
flex-direction: column;
background-color: white;
border-radius: 12px;
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.2);
padding: 16px;
gap: 8px;
animation: bounce 2s ease-in-out infinite;
}
Let's now animate a list item upon a click:
import { useNavigate } from "react-router";
type Props = {
body: string;
id: number;
title: string;
};
const PostItem = ({ body, id, title }: Props) => {
const navigate = useNavigate();
const onTap = () => {
navigate(`/post/${id}`);
};
return (
<view
className="post-item"
id={`post-${id}`}
bindtap={() => {
lynx.getElementById(`post-${id}`).animate(
[
{
transform: "rotate(20deg)",
"animation-timing-function": "linear",
},
{
transform: "rotate(0deg)",
"animation-timing-function": "linear",
},
{
transform: "rotate(-20deg)",
"animation-timing-function": "linear",
},
{
transform: "rotate(0deg)",
"animation-timing-function": "linear",
},
],
{
name: "shake-rotate-anim",
duration: 1000,
iterations: 1,
easing: "ease-in-out",
},
);
}}
>
<text className="post-item-title">{title}</text>
<text className="post-item-content">{body}</text>
</view>
);
};
export default PostItem;
Web Compatibility
Lynx offers default web compatibility, but setting it up is a bit more complex compared to what Expo offers.
First, we need to modify our lynx.config.ts
file at the root:
import { defineConfig } from "@lynx-js/rspeedy";
import { pluginQRCode } from "@lynx-js/qrcode-rsbuild-plugin";
import { pluginReactLynx } from "@lynx-js/react-rsbuild-plugin";
export default defineConfig({
plugins: [
pluginQRCode({
schema(url) {
// We use `?fullscreen=true` to open the page in LynxExplorer in full screen mode
return `${url}?fullscreen=true`;
},
}),
pluginReactLynx(),
],
environments: {
web: {
output: {
assetPrefix: "/",
},
},
lynx: {},
},
});
Then we create a bundle:
yarn build
Next, we create an rsbuild
project at the root of our Lynx project:
yarn create rsbuild
In our new project, we install new dependencies:
yarn add @lynx-js/web-core @lynx-js/web-elements
We then edit the src/App.tsx
file:
import "@lynx-js/web-core/index.css";
import "@lynx-js/web-elements/index.css";
import "@lynx-js/web-core";
import "@lynx-js/web-elements/all";
const App = () => {
return (
<lynx-view
style={{ height: "100vh", width: "100vw" }}
url="/main.web.bundle"
></lynx-view>
);
};
export default App;
Next, we edit the rsbuild.config.ts
file:
import { defineConfig } from "@rsbuild/core";
import { pluginReact } from "@rsbuild/plugin-react";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default defineConfig({
plugins: [pluginReact()],
server: {
publicDir: [
{
name: path.join(__dirname, "..", "dist"),
},
],
},
});
Then we launch our development server:
yarn dev
The app is available on http://localhost:3000
. Inspecting the rendered HTML, we notice a less conventional structure, filled with web components, and very few native tags. Our click animation implemented previously does not work either. We have this feeling that web support is not really at the point, but it is present!
Performances
In terms of performance, Lynx is quite similar to what React Native offers but includes an additional feature: Instant First-Frame Rendering, which is very similar to the concept of SSR known in the React ecosystem. IFR allows the first screen of our application to be displayed almost instantaneously.
Architecturally speaking, Lynx, like React Native, uses two threads. It is possible to choose on which thread to execute our functions using the directives 'background only'
and 'main thread'
depending on the usage context. For example, for an asynchronous request like submitting a form, we would prefer the 'background only'
directive to avoid blocking the main thread. For a function that requires a result as quickly as possible, we would prefer the 'main thread'
directive. This concept is reminiscent of the worklets in Reanimated, allowing the execution of functions in the UI thread or other threads.
Conclusion
Although promising in many aspects, Lynx is currently too young to be used in the same way as React Native. It feels like the library was developed to be integrated into an existing app, rather than to create a complete application. Many essential APIs are missing compared to what is available in React Native. I was personally surprised to see that Lynx only supports the input
element through the Lynx Explorer application, without which it would be impossible to have an input field.
All these issues have already been reported by the community and will be addressed gradually in the upcoming releases of 2025. For now, it is still too early to embark on designing an application with Lynx, but we will certainly keep an eye on the library's evolution.