15 avril 2025
Lynx: le remplaçant de React Native ?

10 minutes de lecture

Sorti en open source il y a maintenant plusieurs semaines, Lynx est un nouvel arrivant dans le monde du développement mobile. Développé à l'origine par ByteDance, la librairie est déjà utilisée en production au sein de l'application TikTok, de la même manière que Facebook utilisait React Native au sein de ses applications mobiles. Maintenant disponible au grand public, la question se pose de savoir si Lynx peut devenir un concurrent voire un remplaçant de React Native.
Comparatif technique
En parcourant la documentation officielle, on se rend tout de suite compte que Lynx a puisé beaucoup d'inspiration sur des frameworks déjà existants, notamment React Native et Flutter, afin d'offrir une expérience de développement très proche de celle que l'on pourrait retrouver sur une application Web :
- Bundler moderne basé sur Rspack
- Librairie React basée sur React 17, permettant de faire le pont avec les composants natifs
- Support des feuilles de style CSS et de la majorité des propriétés CSS
- Support des sélecteurs, que ce soit pour du CSS ou au runtime
De la même manière qu'Expo Go, Lynx fournit une application à installer sur son simulateur, Lynx Explorer, permettant d'exécuter un bundle depuis une URL (locale ou distante). C'est d'ailleurs grâce à cela qu'il est possible d'exécuter les différents exemples fournis dans la documentation.
Une application de DevTools est aussi disponible avec un inspecteur d'élément (HTML et natifs) et une console JavaScript. On notera l'absence d'un inspecteur de réseau, fortement appréciable du côté des DevTools Expo.
Côté runtime, on retrouve PrimJS en tant que moteur JavaScript, développé spécifiquement pour Lynx, de la même manière que React Native utilise Hermes.
Niveau support, Lynx a été pensé pour supporter les plateformes mobiles ainsi que le web, de la même manière que React Native supporte Android, iOS (Windows, MacOS, Web supportés aussi par l'intermédiaire de librairies tierces).
La découverte par la pratique
Pour découvrir plus en détail Lynx, nous allons développer une simple application de deux écrans, utilisant l'API JSON Placeholder, pour lister des posts, accéder aux détails d'un post et y lister des commentaires.
Création du projet
Créer un projet Lynx est assez simple :
yarn create rspeedy
Après que le projet soit créé, il ne reste plus qu'à installer les dépendances :
yarn install
Puis on lance notre serveur de développement :
yarn dev
Une URL va être générée. Il suffit de copier-coller cette URL dans l'application Lynx Explorer, et voilà !
Notre premier écran
Entrons dans le vif du sujet en effacant tout le rendu du composant App
, et créons notre liste de posts :
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;
Et voilà, notre liste est prête. On note déjà plusieurs aspects qui diffèrent par rapport à React Native :
- pas d'import de composants (View, Text) : ils sont disponibles via le moteur de rendu (ici la librairie React)
- possibilité d'utiliser des classes CSS. Il est aussi possible de mettre le style directement dans une prop
style
ou bien d'utiliser l'attributid
, comme en HTML classique
L'élément list
a un comportement qui se veut similaire à ce que propose la FlatList de React Native. Cependant, le concept de recyclage des vues est ici totalement natif, là où React Native l'implémente du côté JS, même si plusieurs librairies alternatives tentent de résoudre ce problème (FlashList, Legend List, ShadowList). L'API fournie pour la liste de Lynx est assez complète, rendant ainsi le système de layout très modulable. Par exemple, pour des listes multi colonnes, il est assez facile de faire qu'un sorte qu'un élément en particulier prenne toute la largeur disponible. La documentation fournit de nombreux exemples présentant différents types de listes assez populaires.
Les plus attentifs auront noté le nom de la prop bindscrolltolower
, en particulier le préfixe bind
. Contrairement à React et React Native où les événements ont tendances à être préfixés par un on
(par exemple onPress
), Lynx utilise différents préfixes en fonction de la manière dont vous souhaitez intercepter un événement.
Détail d'un post
Naturellement, nous souhaitons maintenant pouvoir cliquer sur un post pour afficher son détail ainsi que ses commentaires. Nous aurons donc besoin d'un système de navigation...inexistant ! En tout cas, ce système n'est pas fourni par Lynx, qui préconise plutôt d'utiliser React Router
en version 6. Alors évidemment ça n'est pas idéal : contrairement à React Navigation qui fournit des composants mappés sur des écrans natifs (donc avec des animations natives), on n'aura aucune animation de navigation, aucun composant natif de navigation (header, tab bar, etc) ou même de possibilité d'interactivité avec les gestures avec React Router. Nous allons tout de même utiliser React Router
pour gérer la navigation entre les 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;
Un peu d'animation ?
Composante essentielle des applications mobiles, les animations donnent de la vie à nos écrans. En React Native, bien qu'une API d'animation soit disponible, on a tendance à utiliser la librairie Reanimated qui, dans ses dernières versions, a sorti un support expérimental des animations CSS. Eh bien Lynx supporte cela par défaut, que ce soit via les feuilles de style CSS ou bien de manière impérative via une fonction appelée au runtime.
Ajoutons une animation CSS sur notre liste de posts :
@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;
}
Animons maintenant un élément de la liste au moment d'un clic :
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;
Compatibilité Web
Lynx propose une compatibilité web par défaut, mais sa mise en place est un peu plus complexe que ce que propose Expo par exemple.
Tout d'abord il faut modifier notre fichier lynx.config.ts
à la racine :
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: {},
},
});
Puis on crée un bundle :
yarn build
On crée ensuite un projet rsbuild
à la racine de notre projet Lynx :
yarn create rsbuild
Dans notre nouveau projet, on install de nouvelles dépendances :
yarn add @lynx-js/web-core @lynx-js/web-elements
On édite ensuite le fichier src/App.tsx
:
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;
On édite ensuite le fichier rsbuild.config.ts
:
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"),
},
],
},
});
Puis on lance notre serveur de développement :
yarn dev
L'app est disponible sur http://localhost:3000
. En inspectant le HTML rendu, on remarque une structure peu conventionnelle, remplie de web components, et très peu de tags natifs. Notre animation au clic implémentée précédemment ne fonctionne pas non plus. On a ce sentiment que le support web n'est pas vraiment au point, mais il est présent !
Performances
Côté performances, Lynx est assez similaire à ce que propose React Native, mais propose une fonctionnalité supplémentaire : l'Instant First-Frame Rendering, qui est très semblable au concept de SSR que l'on connaît dans l'univers de React. L'IFR permet d'afficher le premier écran de notre application de manière quasi instantanée.
Architecturalement parlant, Lynx utilise, tout comme React Native, deux threads. Il est possible de choisir sur quel thread exécuter nos fonctions grâce aux directives 'background only'
et 'main thread'
en fonction du contexte d'utilisation. Par exemple pour une requête asynchrone comme l'envoi d'un formulaire, on privilégiera la directive 'background only'
afin de ne pas bloquer le thread principal. Pour une fonction qui demande un résultat le plus rapidement possible, on privilégiera la directive 'main thread'
. Ce concept n'est pas sans rappeler les worklets de Reanimated, permettant l'exécution de fonctions dans le thread UI ou d'autres threads.
Conclusion
Bien que prometteur sur bien des aspects, Lynx est à l'heure actuelle encore trop jeune pour être réellement utilisé de la même manière que l'on pourrait utiliser React Native. On a ce sentiment que la librairie a été dévelopée pour être intégrée à une app existante, et non pour créer une application complète. Beaucoup d'API assez essentielles sont manquantes par rapport à ce que l'on peut trouver en React Native. J'ai personnellement été surpris de voir que Lynx ne supporte l'élément input
que par l'application Lynx Explorer, sans qui on ne pourrait tout simplement pas avoir de champ de saisie.
Tous ces éléments ont déjà été remontés par la communauté et seront déployés petit à petit dans les prochaines releases de 2025. Pour le moment, il est encore trop tôt pour se lancer dans la conception d'une application, mais nous allons tout de même garder un oeil sur l'évolution de la librairie.