React Native Integration & Best Practices: Platform Separation
Platform Separation Strategy
Archibald builds one codebase into several platforms. In the shop template those are shop (the web storefront, and the base every other platform falls back to), app (the React Native/Expo application) and portal (a second web platform):
// archibald.json
{
"project": {
"platforms": ["shop", "app", "portal"]
},
"cli": {
"native": {
"platforms": ["app"]
}
}
}
The goal of platform separation is to keep everything that is not rendering — data fetching, validation, business rules — in one place, and to let only the view layer diverge.
Folder-based separation (the approach used here)
This is the strategy Archibald projects use. A platform overrides a file by placing its own file at the same relative path inside its platform folder. Nothing is renamed, and the importing code is unchanged: the compiler resolves the most specific existing file and falls back down a fixed chain.
- platform + tenant —
src/{platform}/…/tenant/{tenant}/… - platform —
src/{platform}/… - tenant on the default platform —
src/{defaultPlatform}/…/tenant/{tenant}/… - base — the default-platform file (
src/shop/…)
The rules come from project.shadowing.config in archibald.json. The shop template declares a single template covering all four areas:
{
"path": "src/{platform}/{area}(/tenant/{tenant})",
"area": ["client", "server", "service-worker", "resources"]
}
See the Shadowing feature guide for the full rule syntax, wildcards and precedence.
Example: the Button atom
Button exists at the same path under both platforms, so a shared import of shop/client/components/atoms/button/Button gets the web file on the shop build and the native file on the app build:
src/
├── app/ ← React Native platform
│ └── client/components/atoms/button/Button.tsx ← picked by the `app` build
└── shop/ ← base / web platform
└── client/components/atoms/button/Button.tsx ← picked by the `shop` build, and the fallback
Around 58 client files in the shop template are shadowed this way.
What has to match, and what does not
The two files must be interchangeable as modules — same default export, same exported names — because shared code imports them by path without knowing which one it will get.
Their props do not have to be identical, and in practice they are not. The web Button takes onClick, className and a Ref<HTMLButtonElement>; the native one takes onPress, testID and StyleProp<ViewStyle>, and renders a Pressable styled with react-native-unistyles. Forcing a DOM-shaped API onto the native component would be worse than letting each side be idiomatic. What this does mean is that a shared component cannot pass platform-specific props down to a shadowed child — if it needs to, that component belongs in the platform folders too.
Keep shared logic in the base platform
Do not copy hooks, actions, interfaces or utilities into a platform folder. They stay in src/shop/ and both platforms import them from there — roughly 130 files under src/app/ already import from shop/:
// src/app/client/features/account/components/change-address/ChangeAddress.tsx
import { useAddress, useAccountAddressMutation } from 'shop/client/features/account/hooks';
import { useCountries } from 'shop/client/hooks';
import Button from 'app/client/components/atoms/button/Button';
import InputField from 'app/client/components/atoms/form/fields/input/InputField';
The web ChangeAddress imports exactly the same three hooks and its own set of components. Only the markup differs.
Code reusability in practice
1. Put the logic in a hook
Data fetching, mutations and derived state belong in a hook that never touches a platform primitive. useCountries is a complete example — it is nine lines and works unchanged on both platforms:
// src/shop/client/hooks/useCountries.ts
import { type FetchOptions, useFetch } from '@archibald/client';
import { useLanguage } from '@archibald/core';
import { actionGetCountries } from 'shop/client/actions/countries';
export function useCountries(options?: FetchOptions) {
const language = useLanguage();
return useFetch({ key: ['countries', language], data: () => actionGetCountries(), ...options });
}
Framework hooks behave the same way. useProduct from @archibald/product is called identically in the web and the native product detail views:
import { useProduct } from '@archibald/product';
const { data: product } = useProduct(productId);
2. Keep the view thin
A platform view should read as layout over a hook result. Everything below the hook call is markup and styling:
// Native — src/app/client/features/product/screens/product-detail/ProductDetailScreen.tsx
const { data: product, isError: isProductError } = useProduct(productId);
// Web — src/portal/client/features/product/components/detail/ProductDetailComponent.tsx
const { data: product } = useProduct(productId);
3. Reach for the shared abstractions before writing a platform file
Storage, theming and the UI primitives already have cross-platform wrappers. Use them and the component often stays shared. See Data Abstractions and UI Abstractions.
How the framework packages separate platforms
The packages under packages/ cannot use folder-based shadowing — there is no src/app/ to shadow into. They use conditional package entry points instead.
Each package's package.json declares a react-native condition in its exports map that points at a native build:
// packages/client/package.json — abridged; each condition also carries import/require + types
{
"exports": {
".": {
"react-native": { "default": "./lib/native.js" },
"browser": { "import": { "default": "./lib/client.mjs" } },
"node": { "import": { "default": "./lib/node.mjs" } }
}
}
}
The condition order matters: react-native is listed first, so a native build never falls through to browser or node.
Metro honours that map because Archibald turns package-exports resolution on (config.resolver.unstable_enablePackageExports = true in packages/native/src/config/metro-common.ts). A native build therefore gets lib/native.js, built from src/native.ts, which explicitly re-exports the platform-specific modules:
// packages/client/src/native.ts
export { Head } from './components/support/head/head.native';
export { SafeHTML } from './components/support/safe-html/safe-html.native';
export { useIsVisible } from './hooks/misc/useIsVisible.native';
export { useScript } from './hooks/script/useScript.native';
export * from './providers/head.native';
while src/client.ts exports the base modules for the web. Same public name, different file per platform. The complete set of native overrides today:
Base module (in packages/) | Browser-only API it depends on | What the .native.* file does |
|---|---|---|
client/src/hooks/misc/useIsVisible.ts | IntersectionObserver | Real React Native implementation |
client/src/hooks/script/useScript.ts | <script> injection | Returns null |
client/src/components/support/head/head.tsx | <head> | Renders null |
client/src/providers/head.tsx | <head> | Renders children unchanged |
client/src/components/support/safe-html/safe-html.tsx | dangerouslySetInnerHTML, DOMPurify | Renders null |
storefront/src/components/support/registry/registry.tsx | Lazy chunk loading | Alternative registry, no preloading |
storefront/src/hooks/misc/useSynchonizedAnimations.ts | document.getAnimations() | Returns an inert ref |
Note the pattern: most overrides are deliberate no-ops, so a shared component that renders <Head> or <SafeHTML> on native still compiles and runs — it just renders nothing. Only useIsVisible and registry.native carry a genuine second implementation.
The .native suffix here is only a filename convention — every one of these files is imported by its full, explicit path from src/native.ts. Nothing in this repository relies on Metro picking a suffix implicitly.
Metro can do that (Expo's default config resolves Foo.native.ts ahead of Foo.ts for an import './Foo'), and the one platform-suffixed file in the templates — app/client/features/product/components/sort/sort-picker/modal-content/ModalContent.ios.tsx — is likewise imported as .../ModalContent.ios, with no unsuffixed base file beside it. Treat implicit suffix resolution as unused here.
There is no .web.tsx counterpart. The web build's resolve.extensions is ['...', '.ts', '.tsx', '.jsx', 'js'] — a file named MyComponent.web.tsx is never resolved by an import of ./MyComponent and will silently not be bundled. The pair is base file + .native.* override, not .web.* + .native.*.
In application code, prefer folder-based separation. Suffixes scatter platform variants through feature folders and are invisible to the tenant/platform shadowing chain.
The "No DOM in Native" Rule
React Native does not run in a browser and has no DOM. Any reference to window, document, navigator or localStorage that is reachable from a shared module will crash on device.
- Guard nothing, abstract instead. The framework already wraps the common cases — persistence goes through
NativeStorage, which wraps@react-native-async-storage/async-storageand mirrors the web'slocalStorage-backed implementation behind one API (see Data Abstractions). - If a browser-only API has no abstraction yet, keep the module in the platform folder rather than branching on a runtime check inside shared code.