●TEST — The Rork Companion app lets you test on a real iPhone without a paid Apple Developer account●CLOUD — Code compiles on a cloud Mac, streaming a 60fps live simulator with real touch input●BROWSER — Design, code, and test entirely in Chrome or Safari — no Xcode required●PUBLISH — Two-click App Store publishing keeps the submission process simple●MAX — Rork Max builds native Swift apps for iPhone, iPad, Apple Watch, and Vision Pro●RN — Standard Rork generates iOS and Android apps together with React Native (Expo)●TEST — The Rork Companion app lets you test on a real iPhone without a paid Apple Developer account●CLOUD — Code compiles on a cloud Mac, streaming a 60fps live simulator with real touch input●BROWSER — Design, code, and test entirely in Chrome or Safari — no Xcode required●PUBLISH — Two-click App Store publishing keeps the submission process simple●MAX — Rork Max builds native Swift apps for iPhone, iPad, Apple Watch, and Vision Pro●RN — Standard Rork generates iOS and Android apps together with React Native (Expo)
Build Your Settings Screen From One Schema and Reuse It Across Apps
Hand-building a settings screen per app falls apart as your app count grows. Here is a schema-driven design that assembles the screen from declarative data and shares a common base across multiple apps, with working code.
A settings screen causes no trouble while you run a single app. You add a toggle, add a row, write it straight into the screen, and it works. The pain starts when a second and third similar app appear. As an indie developer at Dolice, I run several small wallpaper and calm-down apps in parallel, and at some point I realized I was copying the same setting rows into separate screens over and over. Theme switch, notifications on/off, clear cache — rows that exist in every app, each carried as a separate implementation, one per app.
When rows live directly inside screens, every change becomes "number of apps × number of edit sites." Changing a single word in the notification setting means opening six places. That is less a workload problem than a structural one: it is a structure that breeds omissions.
This article separates the settings screen into data (a schema) and rendering (a renderer), and shares the data across apps. It is written to drop straight into an Expo app generated by Rork.
What to peel off the screen
Break down what a settings screen is responsible for and you get roughly four things.
Responsibility
Example
Where it belongs
Item definition
A "Dark mode" toggle exists
Schema (shared)
Value storage
Persist true/false on the device
Store (shared)
Appearance
Drawing rows, switches, dividers
Renderer (shared)
Specific behavior
An "Unlock Pro" row only this app has
Per-app difference
The first three are nearly identical in every app. Only the fourth differs. So if you share the three shareable parts ruthlessly and let the fourth be added as a small "difference," operations get dramatically lighter.
Define the schema with types
Start by deciding the type that represents a setting item. This is the backbone of the design. Responsibility is concentrated in this type, not in the screen.
// settings/schema.tsexport type SettingKind = "toggle" | "select" | "action" | "link";export type SettingItem = { key: string; // persistence key; never collide across apps kind: SettingKind; titleKey: string; // i18n key; hold no raw strings // default for toggle / select defaultValue?: boolean | string; // options for select options?: { value: string; labelKey: string }[]; // what action / link does onPress?: () => void | Promise<void>; // visibility condition; the whole row disappears if unmet visible?: (ctx: SettingsContext) => boolean;};export type SettingSection = { titleKey: string; items: SettingItem[];};export type SettingsContext = { isPro: boolean; platform: "ios" | "android";};
Three things matter. Keep titleKey as an i18n key rather than a raw string. Make visible a function so conditions like "show only for Pro members" can be written declaratively. And give the item no behavior beyond onPress. The schema only says "what exists"; it never holds "how to draw."
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Collapse setting definitions into one declarative schema and let the UI generate itself
✦Where to draw the line between a shared base and per-app differences
✦Pushing persistence, validation, and ordering into the schema layer
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Place the sections you want to share in a separate file as constants. If multiple app repositories reference the same file, that file becomes the single source of truth.
App-specific rows only "add" without breaking the shared sections. For example, the Unlock Pro row is assembled only in that app's screen file.
// app/(tabs)/settings.tsximport { appearanceSection, storageSection } from "../../settings/shared-sections";import { SettingSection } from "../../settings/schema";const proSection: SettingSection = { titleKey: "settings.account", items: [ { key: "unlockPro", kind: "action", titleKey: "settings.unlockPro", visible: (ctx) => !ctx.isPro, // the whole row disappears once Pro onPress: () => router.push("/paywall"), }, ],};export const sections: SettingSection[] = [ appearanceSection, proSection, // <- this app's difference storageSection,];
Order is decided purely by array position. If one app wants the Pro row on top and another wants it on the bottom, you only move it in the array.
Write the renderer once
The rendering side just receives the schema and draws rows by kind. This can be fully shared across apps.
// settings/SettingsRenderer.tsximport { View } from "react-native";import { useTranslation } from "react-i18next";import { SettingSection, SettingsContext } from "./schema";import { ToggleRow, SelectRow, ActionRow } from "./rows";export function SettingsRenderer({ sections, ctx,}: { sections: SettingSection[]; ctx: SettingsContext;}) { const { t } = useTranslation(); return ( <View> {sections.map((section) => { const items = section.items.filter((i) => i.visible?.(ctx) ?? true); if (items.length === 0) return null; // drop empty sections, header and all return ( <View key={section.titleKey}> <SectionHeader title={t(section.titleKey)} /> {items.map((item) => { switch (item.kind) { case "toggle": return <ToggleRow key={item.key} item={item} />; case "select": return <SelectRow key={item.key} item={item} />; default: return <ActionRow key={item.key} item={item} />; } })} </View> ); })} </View> );}
Filter out rows that fail visible, then drop sections that became empty — header and all. Those two lines alone prevent a common settings-screen glitch: a lonely header left behind after Pro members no longer need its rows.
Let the schema own the storage keys
Persistence reuses the schema's key and defaultValue directly. The screen knows nothing about which key is stored where.
// settings/store.tsimport AsyncStorage from "@react-native-async-storage/async-storage";import { useEffect, useState } from "react";import { SettingItem } from "./schema";const NS = "settings.v1."; // a namespace keeps these from mixing with other keysexport function useSetting(item: SettingItem) { const [value, setValue] = useState(item.defaultValue); useEffect(() => { AsyncStorage.getItem(NS + item.key).then((raw) => { if (raw != null) setValue(JSON.parse(raw)); }); }, [item.key]); const update = async (next: boolean | string) => { setValue(next); await AsyncStorage.setItem(NS + item.key, JSON.stringify(next)); }; return [value, update] as const;}
The namespace NS pays off later, when you want to swap the backend to MMKV or SQLite: key collisions and migration get easier. Because the schema owns the canonical keys, the migration script is just "walk the schema, move old key to new key."
Where this helps, and where it does not
It is not universal. For a one-off app with fewer than ten setting rows, writing straight into the screen is faster. Schema-driven design pays off when you have several apps with similar settings, or you know the settings will grow. In my case, fixing the notification wording once in the schema reflects across every app — that single point alone made it worth adopting.
Conversely, an elaborate settings screen where each row has a wildly different layout (a slider with preview, an embedded map) should not be forced into the schema. For those, prepare a single kind: "custom" with an escape hatch that takes a render function, and you absorb the exception without breaking the schema's consistency.
If you want a place to start, carving just the three rows every app has — theme, notifications, clear cache — into a shared section is enough. The moment that becomes the source of truth, the settings screen stops being the place that grows heavier in proportion to your app count.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.