Frontend
Native screens
@stone-js/use-react-native is the view dimension on a phone. A resolved route becomes a screen, screens form a stack, and everything a page is made of comes from the same place the web renderer takes it from. There is no native variant of a page to learn.
#A screen is a page
The @Page decorator, layouts, error pages, view providers and the hooks all come from @stone-js/use-react-core, shared with the web renderer. A component imports them from the renderer it runs on, and nothing else changes.
import { View, Text } from 'react-native'
import { IPage, Page, PageRenderContext, ReactIncomingEvent } from '@stone-js/use-react-native'
@Page('/tasks/:id')
export class TaskScreen implements IPage<ReactIncomingEvent> {
constructor (private readonly tasks: TaskService) {}
async handle (event: ReactIncomingEvent) {
return await this.tasks.find(event.get('id'))
}
head ({ data }) {
return { title: data.title } // what a navigator shows in its header
}
render ({ data }: PageRenderContext<Task>) {
return <View><Text>{data.title}</Text></View>
}
}import { View, Text } from 'react-native'
import { definePage, IPage, ReactIncomingEvent } from '@stone-js/use-react-native'
export const TaskScreen = ({ tasks }: { tasks: TaskService }): IPage<ReactIncomingEvent> => ({
async handle (event) {
return await tasks.find(event.get('id'))
},
head ({ data }) {
return { title: data.title }
},
render ({ data }) {
return <View><Text>{data.title}</Text></View>
}
})
export const TaskScreenBlueprint = definePage(TaskScreen, { path: '/tasks/:id' })On a phone there are no meta tags, so head means something slightly different and nothing more: the title is what a navigator shows in its header.
#The stack, as plain state
This is the seam of the whole renderer. A browser has one document and replaces its contents; a phone has a stack of screens, each keeping its own state, with a back gesture that pops the top one. So the renderer does not render: it puts what the kernel resolved onto a stack, and whatever displays screens reacts.
The stack is public state, which is what lets you choose who displays it.
Keeping it as plain state, with no React and no navigation library in sight, has three consequences worth naming. A first run works with nothing installed. A real navigator can drive itself from the same object without this package depending on it. And the navigation semantics are testable without a device.
#Showing the screens
#The floor
StoneNativeApp shows the screen on top of the stack. It is the simplest thing that works, with nothing to install and nothing to link.
import { StoneNativeApp } from '@stone-js/use-react-native'
export default function App () {
return <StoneNativeApp fallback={<Splash />} />
}#A real navigator
The platform's own transitions, the swipe-back gesture, the hardware back button, and a screen keeping its own state while another covers it are things only a native navigator gives you, and none of them can be imitated in JavaScript. Two commands and one import away.
npx expo install @react-navigation/native @react-navigation/native-stack \
react-native-screens react-native-safe-area-contextimport { StoneNativeStack } from '@stone-js/use-react-native/navigation'
export default function App () {
return <StoneNativeStack screenOptions={{ headerShown: true }} />
}Nothing about your pages changes. Each screen becomes a native one, keyed by its own identity so the navigator keeps its state as the stack grows, and titled from the page's head.
#The one thing worth understanding
There are two stacks and one truth. The router owns navigation, so Stone's stack is the truth and the navigator displays it. A screen can then leave the navigator for two different reasons, and only one of them needs answering.
- The user swiped back, or pressed the hardware button. The navigator removed the screen and the framework knows nothing about it, so its stack still has the screen on top. It gets popped, and the two agree again.
- The framework popped it already, through
useGoBackor a reset. The navigator is only catching up with a render it was given, and popping again would eat the screen underneath.
Comparing the departing screen's key with what the stack now has on top separates the two exactly, with no flag to keep and no window in which a fast double-back does the wrong thing. That comparison is shouldPopStone, exported next to the component, and it is the part to read before writing your own navigator.
#Navigating
Navigation goes through the router, so the route is matched, its loader runs and its middleware runs, exactly as they would for a deep link. A screen never renders another screen itself.
import { useNavigate, useGoBack } from '@stone-js/use-react-native'
const navigate = useNavigate()
navigate('/tasks/42') // push a screen
navigate('/tasks/42', 'replace') // swap the current one
navigate('/sign-in', 'reset') // start again, leaving no history
navigate({ name: 'tasks.show', params: { id: 42 } }) // by route nameuseGoBack() returns { goBack, canGoBack }. Wire goBack to your header button and to Android's hardware button, and let the platform leave the application when canGoBack is false. The stack never pops its last screen: an application always displays something, and a back gesture on the first screen is the platform's business.
#Nothing lists your screens
A web application never lists its pages: the build collects them. A native one should not have to either, and the only reason it once did is that collection is a bundler question. The web build asks Vite for import.meta.glob; Metro has no such thing and would not understand one.
So the question is answered before any bundler runs. withStone wraps a Metro configuration, collects everything under app/ and writes .stone/modules.ts: real static imports, which is what Metro needs to see, extensionless so per-platform files such as HomePage.ios.tsx still win as they would for hand-written code, and sorted so the file is byte-identical between two runs on the same tree.
const { getDefaultConfig } = require('expo/metro-config')
const { withStone } = require('@stone-js/use-react-native/metro')
module.exports = withStone(getDefaultConfig(__dirname), __dirname)import { modules } from './.stone/modules'
stoneApp({ modules }).run()Add .stone/ to your .gitignore. If you need the file without starting a bundler, for a type-check on a fresh clone or a CI step that does not bundle, writeManifest is exported from the same entry.
{
"scripts": {
"modules": "node -e \"require('@stone-js/use-react-native/metro').writeManifest(process.cwd())\"",
"typecheck": "npm run modules && tsc --noEmit"
}
}#Developing in a browser
The fastest loop on a native application is not a simulator, it is a browser tab. Expo serves a React Native application to one through react-native-web, with Fast Refresh, and the same code then runs on a device untouched.
npx expo install react-dom react-native-web
npx expo start --webWhat you get is the real thing: your routes resolve, your loaders run, your screens render, deep links arrive as URLs. What you do not get is anything a browser cannot do, and that is worth knowing before trusting the loop for a given screen. react-native-web covers the core primitives, not every native module, so a screen built on the camera, on secure storage or on a native gesture handler has to be tried on a device. Layout is close but not identical, and performance in a tab says nothing about a phone.
#Testing
Your domain, your routes and your loaders test without a device or a simulator, in the same shape a web application uses. Name the platform, because a native application's renderer registers itself against it, and send the event a phone receives.
import { createTestApp } from '@stone-js/testing'
import { makeIncomingBrowserEvent } from '@stone-js/testing/browser'
import { REACT_NATIVE_PLATFORM } from '@stone-js/react-native-adapter'
const app = await createTestApp({ platform: REACT_NATIVE_PLATFORM })
const response = await app.send(makeIncomingBrowserEvent({ url: 'myapp://tasks/42' }))
expect(response.statusCode).toBe(200)A deep link is a URL with your own scheme, and the factory keeps schemes rather than resolving them away, so the route a phone reaches is the route a test reaches. For the native question, what lands on the stack and in what order, supply your own screen stack and navigation source and assert against them: both are configuration, not internals.
#Configuration
| Option | Type | Description |
|---|---|---|
| stone.useReactNative.screenStack | ScreenStack | The navigation stack. Created for you during the build phase and shared with the runtime, the response middleware and the components. Set it to supply your own, which is how a test reads what landed. |
| stone.useReact.* | various | Pages, layouts, error pages and view providers are configured under the same keys the web renderer reads, because they are declared the same way and there is no reason for a page to be declared twice. |