| name | add-feature |
| description | Use when adding a new screen, route, or user-facing feature to the Ecency mobile React Native app, covering navigator registration, route params, i18n strings and styling. |
| argument-hint | ["feature-name"] |
Add Feature
Ordered procedure for adding a screen. CLAUDE.md covers Redux, TanStack Query, @ecency/sdk,
sheets and lint rules in more depth. Easiest to miss: the safe-area root (Step 1) and the params
contract (Step 4). Each one breaks something.
New screens are functional. A few older screens under src/screens/ are still classes, plus
application/children/errorBoundary.tsx, which React requires to be a class. An overlay with no
route of its own, shown with SheetManager.show, is a bottom sheet instead:
src/navigation/sheets.tsx, see CLAUDE.md.
src/screens/<feature>/
index.ts # local barrel, re-exported from src/screens/index.ts
screen/<feature>Screen.tsx
screen/<feature>Styles.ts # or <feature>.styles.ts
children/ hooks/ # optional
Step 1: screen rooted in SafeAreaView
Use SafeAreaView from react-native-safe-area-context as the root; that is where screens
generally import it from. src/screens/dappBrowser/screen/dappBrowser.tsx still takes it from
react-native, which is the pattern this rule exists to replace. Do not pass edges: it
replaces the defaults rather than extending them, so edges={['bottom']} drops the top inset
and the header runs under the status bar. That shipped on the Email digests screen; the fix was
removing edges={['bottom']}.
Skeleton for screen/<feature>Screen.tsx (a template, not a quote of any one file):
import React from 'react';
import { useIntl } from 'react-intl';
import { SafeAreaView } from 'react-native-safe-area-context';
import { BasicHeader } from '../../../components';
import { useAppSelector } from '../../../hooks';
import { selectCurrentAccount } from '../../../redux/selectors';
import styles from './myFeatureStyles';
const MyFeatureScreen = () => {
const intl = useIntl();
const currentAccount = useAppSelector(selectCurrentAccount);
return (
<SafeAreaView style={styles.container}>
<BasicHeader title={intl.formatMessage({ id: 'myfeature.title' })} />
{/* content */}
</SafeAreaView>
);
};
export default MyFeatureScreen;
useAppSelector(selectCurrentAccount) reads the account; useAuth() from src/hooks when you
only need { username, code }. Reusable UI is exported from the src/components/index.tsx barrel
(BasicHeader, MainButton, TextInput, UserAvatar, Icon).
The styles file default-exports
EStyleSheet.create({ container: { flex: 1, backgroundColor: '$primaryBackgroundColor' } }).
Variables are defined in src/themes/lightTheme.ts and darkTheme.ts: $primaryBlack text,
$primaryDarkGray secondary text, $primaryBlue accent, $primaryLightBackground cards,
$iconColor, $primaryRed destructive. Hex literals do not follow the theme.
Step 2: both barrels
src/screens/<feature>/index.ts does import MyFeature from './screen/myFeatureScreen'; then
export { MyFeature }; export default MyFeature;. Add the import plus the name to the export block
in src/screens/index.ts. stackNavigator.tsx imports its screens from that barrel.
src/screens/waves shows the cost of skipping this: it never reached the barrel, so
botomTabNavigator.tsx reaches it by path.
Step 3: route name
In src/constants/routeNames.ts. Entries are template literals over the shared suffix consts. The
object ends as const, which is what makes the route names a literal union:
MY_FEATURE: `MyFeature${SCREEN_SUFFIX}`,
Step 4: params contract (skip it and typecheck fails)
src/navigation/types.ts derives RouteName from ROUTES, then asserts every route has an entry:
export type _MissingRouteContracts = AssertNever<Exclude<RouteName, keyof AppParamList>>;
A ROUTES entry with no AppParamList entry breaks that assertion: the route name does not satisfy
the constraint never. yarn typecheck runs against an empty baseline, so this fails CI. Add:
[ROUTES.SCREENS.MY_FEATURE]: { username?: string } | undefined;
Append | undefined only if the screen renders with no params. Leaving it off makes params required
at the call sites, which is what you want for a screen that cannot render empty (WEB_BROWSER,
VOTERS, ASSET_DETAILS, CHAT_THREAD, PROFILE_EDIT).
Step 5: register in the navigator
src/navigation/stackNavigator.tsx defines MainStackNavigator and the root StackNavigator.
MainStackNavigator registers the drawer as its first screen (ROUTES.DRAWER.MAIN), so an
ordinary screen added to it is a sibling of the drawer that pushes over it. The root
StackNavigator holds MainStackNavigator itself plus pre-auth and full-screen routes (Login,
Register, Welcome, PinCode, WebBrowser). Most new screens go in the main one:
<MainStack.Screen name={ROUTES.SCREENS.MY_FEATURE} component={MyFeature} />
Put it in the <MainStack.Group screenOptions={{ animation: 'slide_from_bottom' }}> block to slide
up, add options={{ presentation: 'modal' }} for a true modal. The as any casts on existing rows
are legacy prop debt; a new screen needs none.
Step 6: navigating
types.ts declares ReactNavigation.RootParamList extends AppParamList, so the untyped hook is
already checked against Step 4. Omitting params for a route that requires them is a compile error,
not a blank screen.
const navigation = useNavigation();
navigation.navigate(ROUTES.SCREENS.MY_FEATURE, { username });
Reading the params back is not settled house style. Most screens destructure a route prop, usually
typed any; useRoute is rare. Prefer keying off Step 4 instead:
import { RouteProp, useRoute } from '@react-navigation/native';
import { AppParamList } from '../../../navigation/types';
const route = useRoute<RouteProp<AppParamList, typeof ROUTES.SCREENS.MY_FEATURE>>();
Outside a component (deep links, redux actions) use the equally typed object form,
RootNavigation.navigate({ name, params }) from src/navigation/rootNavigation.tsx.
Step 7: strings
src/config/locales/en-US.json is the catalog you edit; Crowdin owns the translations. It is
nested: top-level keys are objects, not dotted ids. src/utils/flattenMessages.ts joins the
levels with dots at load, so a nested block is read with a dotted id.
{ "myfeature": { "title": "My Feature", "empty": "Nothing here yet" } }
intl.formatMessage({ id: 'myfeature.title' });
Step 8: data
Server data uses @ecency/sdk query options with TanStack Query; mutations use a wrapper in
src/providers/sdk/mutations/. See the SDK Migration section of CLAUDE.md. Keep Redux for auth,
settings, UI state and the optimistic cache reducer. Older reducers (postsReducer,
walletReducer) still hold server data; do not extend them for a new screen.
Checklist