Day for React Native developers
React Native and Day both use native platform widgets. Day expresses the UI in Rust and updates widgets through signal bindings rather than React reconciliation. This guide maps components to pieces, hooks to signals, and npm packages to Cargo dependencies, with examples of each workflow.
React Native terms in Day
| React Native | Day |
|---|---|
| function component | a plain function returning a Piece |
| JSX | function calls and tuples |
useState | Signal::new |
useMemo | Memo::new (no dependency array) |
useEffect | Effect::new / bind / watch (no dependency array) |
| re-render + reconciliation | no re-render; one binding patches one widget |
| Context | with_environment / environment::<T>() |
| Redux / Zustand / Jotai | signals in plain Rust (module statics, structs) |
<FlatList> | list (recycling), each (keyed) |
| React Navigation | nav, nav_stack, and string routes |
useWindowDimensions() + your own breakpoints | size_class(), and a nav that re-presents itself |
package.json + Metro | Cargo.toml (package) + Day.toml (app manifest) |
require('./logo.png') | image(res::images::logo), a generated constant |
| i18next / react-intl | Fluent .ftl + generated res::str functions |
| Turbo Modules / native components | parts and pieces |
| Fast Refresh | none — day relaunch rebuilds and relaunches |
eas build / fastlane / Gradle + Xcode | day pack -p <target> |
The same counter in both
import { useState } from 'react';
import { View, Text, Button } from 'react-native';
function Counter() {
const [count, setCount] = useState(0);
return (
<View style={{ gap: 12, padding: 16 }}>
<Text>{count} clicks</Text>
<Button title="+" onPress={() => setCount(c => c + 1)} />
</View>
);
}use day::prelude::*;
fn counter() -> impl Piece {
let count = Signal::new(0i64);
column((
label(move || format!("{} clicks", count.get())),
button("+").action(move || count.update(|c| *c += 1)),
))
.spacing(12.0)
.padding(16.0)
}Both wire the tap the same way and differ in what follows it. React re-runs Counter, builds
new element trees, and diffs them against the shadow tree before committing to native views.
Day re-runs the one closure inside label and calls one native setter. The function itself
never runs again, so
memo() and useCallback have no equivalent, and stale closures can’t happen: a closure that
reads a signal reads the current value.
Controlled components vs. two-way signals
An RN TextInput is controlled by a value/onChangeText pair you wire by hand. Day’s inputs
take the signal itself; the two directions are built in, origin-tagged so there’s no echo.
const [name, setName] = useState('');
const [volume, setVolume] = useState(40);
const [subscribed, setSubscribed] = useState(false);
<View style={{ gap: 12 }}>
<TextInput
value={name}
onChangeText={setName}
placeholder="Your name"
/>
<Slider
value={volume}
maximumValue={100}
onValueChange={setVolume}
/>
<Switch value={subscribed} onValueChange={setSubscribed} />
</View>let name = Signal::new(String::new());
let volume = Signal::new(40.0);
let subscribed = Signal::new(false);
column((
text_field(name).placeholder("Your name"),
slider(volume).range(0.0..=100.0),
toggle(subscribed),
))
.spacing(12.0)(Slider lives in @react-native-community/slider, outside RN core. Day ships sliders,
toggles, progress, pickers, and a recycling list as built-ins.)
Lists
FlatList maps to list: both virtualize, and both want a key. Day’s rows recycle native cells
(UITableView-style), and the key function is a required argument, where keyExtractor is
optional, so reconciliation is always by identity.
<FlatList
data={items}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<Text style={styles.row}>{item.title}</Text>
)}
/>list(
move || items.get(),
|item| item.id,
|slot: ItemSlot<Todo, u64>| {
label(move || slot.get().title).padding(8.0)
},
)Navigation
React Navigation’s stack keeps navigation state inside the navigator; you drive it through the
imperative navigation prop. In Day the path is your signal, and the native container
(UINavigationController, the Android back stack) is reconciled to it. The back gesture and
hardware back button write pops into your state.
const Stack = createNativeStackNavigator();
<NavigationContainer linking={linking}>
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Album" component={AlbumScreen} />
</Stack.Navigator>
</NavigationContainer>;
// inside a screen:
navigation.navigate('Album', { id: '42' });let path = Signal::new(Vec::<String>::new());
nav_stack(path, home_page())
.title("Home")
.destination(|key| album_page(key))
// from anywhere — it's just state:
navigate("album-42");
current_route(); // Some("album-42")
nav_back();The same route strings serve navigation, the DAY_DEEPLINK launch variable, and
dayscript test assertions, so deep links need no separate linking config.
See Navigation.
Adaptive layout
React Native gives you useWindowDimensions() and leaves the breakpoints to you (or to a library
like react-native-paper’s adaptive components). Day buckets the window into size
classes (Android’s numbers, unchanged on every backend), and a nav
re-presents itself when the window crosses one, on a tablet, in split-screen, or in a resized
desktop window.
const { width } = useWindowDimensions();
const wide = width >= 840;
return (
<View style={{ flex: 1, flexDirection: wide ? 'row' : 'column' }}>
{wide && <Sidebar items={items} selected={page} onSelect={setPage} />}
<View style={{ flex: 1 }}>{renderPage(page)}</View>
{!wide && <BottomTabs items={items} selected={page} onSelect={setPage} />}
</View>
);// One nav host, both presentations. Day re-resolves on every breakpoint
// crossing and re-homes the pages it already built, so scroll offsets and
// focus survive the morph rather than being rebuilt.
nav(page)
.style(NavStyle::Sidebar)
.destination(|key| page_for(key))
// Where the app wants to make the same call itself:
let two_up = size_class().is_some_and(|c| c.width >= WidthClass::Expanded);The class is per window, so an app showing two windows at different sizes gets a separate answer for each.
Styling and layout
RN styles are flexbox via Yoga. Day also owns its layout engine; it uses a proposal protocol (parent proposes, child chooses), closer to SwiftUI than to CSS:
const styles = StyleSheet.create({
card: {
padding: 16,
backgroundColor: '#1E293B',
borderRadius: 12,
alignItems: 'flex-start',
gap: 8,
},
title: { fontSize: 22, fontWeight: '600' },
});
<View style={styles.card}>
<Text style={styles.title}>Plan</Text>
<Text>Pro</Text>
</View>;column((
label("Plan").font(Font::Title),
label("Pro"),
))
.spacing(8.0)
.align(HAlign::Leading)
.padding(16.0)
.background(Color::hex(0x1E293B))
.corner_radius(12.0)There are two differences. flex: 1 becomes .grow(), and containers don’t stretch
children by default: a column is as wide as its widest child. And fonts are semantic-first:
Font::Title resolves to each platform’s typography scale, so text sits correctly next to
native controls without per-platform size tables. Layout and
Styling cover both systems.
State management and async
The hooks rules (call order, dependency arrays, exhaustive-deps lint) exist because React re-runs your function and must reattach state each time. Day’s function runs once, so none of that machinery exists. Signals are created wherever you like, read anywhere, and dependencies are discovered by tracking the reads:
let items = Signal::new(Vec::<Item>::new());
let total = Memo::new(move || items.with(|v| v.iter().map(|i| i.price).sum::<f64>()));
// No dependency array — this label re-runs exactly when `total`'s value changes:
label(move || format!("{:.2} €", total.get()))
For app-wide state, signals in a module or struct are already observable from any Piece, so a
store library has no role. Async is async/await, with continuations on the UI thread so
completions are plain writes:
const [status, setStatus] = useState('');
useEffect(() => {
let cancelled = false;
(async () => {
const resp = await fetch(url);
const text = await resp.text();
if (!cancelled) setStatus(text);
})();
return () => { cancelled = true; };
}, [url]);use day_part_http::{Request, fetch_future};
let status = Signal::new(String::new());
day::task(async move {
match fetch_future(Request::get(url)).await {
Ok(resp) => status.set(resp.text().into_owned()),
Err(e) => status.set(format!("error: {e}")),
}
});The cancelled flag on the left is unnecessary on the right: writes to a signal whose page was
disposed are silent no-ops, so a late completion can’t touch a closed page. Cancelling the work
itself is explicit: day::task returns a TaskHandle, and handle.abort() drops the future
and cancels the in-flight request; nothing auto-cancels on page teardown. HTTP goes through
each platform’s networking stack (day-part-http), so system proxies, VPN
routing, and certificate stores apply.
Package configuration
// package.json
{
"name": "field-notes",
"version": "1.2.0",
"dependencies": {
"react": "19.2.0",
"react-native": "0.86.0",
"@react-navigation/native": "^7.0.0",
"react-i18next": "^15.0.0"
}
}
// plus: metro.config.js, babel.config.js,
// android/ and ios/ configs for anything native# Cargo.toml — the package
[package]
name = "field-notes"
version = "1.2.0"
edition = "2024"
[dependencies]
day = { git = "https://github.com/daybrite/day.git" }
# Day.toml — the app manifest
[app]
id = "dev.example.fieldnotes"
title = "Field Notes"
build = 34
targets = ["ios-uikit", "android-mdc", "macos-appkit"]Cargo resolves and builds everything, including packages with native halves, in one step. A
package that needs platform code, the Turbo Module case, is a part (headless
capability) or a piece (a widget): it declares its Swift, Java, or C++ in
its own crate metadata, and day build folds that into the app’s platform builds. Calls into
it are ordinary Rust function calls compiled together with your app, typed end to end.
Resources and localization
Metro resolves require('./logo.png') at bundle time and picks @2x/@3x variants; Day stages
resource/images/ into each platform’s native store (asset catalogs, res/drawable-*,
GResource), and the platform picks the density. References are generated constants
(image(res::images::wave)), so a renamed or deleted file is a compile error, the same
guarantee Metro gives at bundle time.
<Image source={require('./assets/wave.png')} />
// i18next
i18n.use(initReactI18next).init({
resources: { en: { translation: en }, fr: { translation: fr } },
lng: 'en',
});
const { t } = useTranslation();
<Text>{t('unread', { count })}</Text>;image(res::images::wave) // resource/images/wave.png + @2x/@3x
// resource/locales/en/app.ftl:
// unread = { $count ->
// [one] 1 unread
// *[other] { $count } unread
// }
res::locales::install(); // registers every locale directory
label(res::str::unread(count)) // generated, compile-checkedres::str::unread is a function generated from the .ftl catalogs at build time. A missing
key or wrong argument count fails the compile instead of rendering a placeholder at runtime.
The locale is a signal: switching languages re-renders every string live, and RTL locales
mirror the whole layout. See Localization and
Resources.
Project structure and per-platform configuration
FieldNotes/
├── package.json
├── metro.config.js
├── babel.config.js
├── App.tsx
├── src/
├── android/ # Gradle project (yours to maintain)
├── ios/ # Xcode project + Podfile
└── node_modules/field-notes/
├── Cargo.toml
├── Day.toml
├── src/
│ ├── lib.rs # the app: pieces, signals, routes
│ └── main.rs # desktop entry point
├── resource/ # assets/ images/ fonts/ icons/ locales/
├── dayscript/ # UI test flows
├── platform/
│ ├── ios/ # Xcode host (no app logic)
│ ├── android/ # Gradle host (no app logic)
│ └── ohos/ # HarmonyOS host
└── build/day/ # everything generatedBoth projects carry android/ and ios/ directories. Day’s hosts are host projects that load
the Rust library, and app-level configuration (id, title, version, window, permissions,
signing) lives in Day.toml, with per-platform overrides:
[app]
id = "dev.example.fieldnotes"
title = "Field Notes"
[app.ios]
title = "Field Notes Mobile" # only iOS sees this
[app.macos-appkit]
id = "dev.example.fieldnotes.mac" # only the macOS target sees this
That’s the role app.json plays in Expo, extended to all eight primary targets, desktop
included. The hosts are already in your repository, so there is nothing to eject.
Building and shipping
| Task | React Native | Day |
|---|---|---|
| run in dev | npx react-native run-ios / run-android | day launch -p ios-uikit / -p android-mdc |
| apply changes | Fast Refresh | day relaunch --all-running |
| Android store build | Gradle bundleRelease + signing config | day pack -p android-mdc → signed .apk + .aab |
| iOS store build | Xcode archive / fastlane / EAS | day pack -p ios-uikit → .ipa via xcodebuild -exportArchive |
| macOS / Windows / Linux | react-native-macos / react-native-windows (separate forks) | day pack -p macos-appkit / -p windows-xaml / -p linux-gtk — .dmg (notarized), .msix + installer, .flatpak + .appimage |
| web | react-native-web (renders DOM via the RN API) | day build -p web-dom — the same crate compiled to WebAssembly, driving DOM elements |
| environment check | npx react-native doctor | day doctor |
Maturity differs by target, and Platform support lists what each target has
today: every (OS, toolkit) pair carries a support tier, from
Tier 1 (thoroughly tested, with shipping apps on it) down to
Tier 4 (development combinations nobody ships).
You give up Fast Refresh and the size of the npm ecosystem, and you write Rust instead of
TypeScript; in return one repository and one dayscript cover mobile, desktop, and web. Signing
secrets are ${ENV_VAR} references in Day.toml; a missing one degrades to a dev-signed build with
a warning rather than failing. Packaging has the full table.
Where to go next
- Getting started — install, scaffold, first launch.
- API tour — examples of common UI components and patterns.
- Reactivity — why there are no dependency arrays.
- Why Day compares the approaches and says when React Native fits better.