- Components:
*.tsxfiles in feature directories - Loaders/Actions: Can be in separate
loaders.tsfiles or co-located with components - Types: Generated types are in
*.generated.tsfiles (don't edit these manually) - GraphQL: Queries/mutations are in
queries.graphqlandmutations.graphqlfiles
app/javascript/: All frontend TypeScript/React codeapp/graphql/: Backend GraphQL schema and resolversapp/javascript/AppContexts.ts: Context providers includingapolloClientContextapp/javascript/useIntercodeApolloClient.ts: Apollo Client setup and configuration
Routes follow a file-based convention similar to Remix/React Router v7:
route.tsxorindex.tsx: Default route component$id.ts: Dynamic route segmentloaders.ts: Loader functions for the route
import { useLoaderData, useRouteLoaderData } from 'react-router';
import { NamedRoute } from './AppRouter';
function MyComponent() {
// For current route's loader data
const data = useLoaderData() as MyQueryData;
// For parent route's loader data (use NamedRoute enum)
const parentData = useRouteLoaderData(NamedRoute.AdminUserConProfile) as ParentQueryData;
}GraphQL operations are defined in .graphql files and types are generated using graphql-codegen:
# Generate types after modifying .graphql files
yarn graphql:codegenWhen mutating data, update the Apollo Client cache to reflect changes:
await client.mutate({
mutation: DeleteItemDocument,
variables: { id },
update: (cache) => {
cache.modify({
id: cache.identify({ __typename: 'ParentType', id: parentId }),
fields: {
items: (existing, { INVALIDATE }) => INVALIDATE,
},
});
},
});The codebase uses react-router's form handling with fetchers:
import { useFetcher } from 'react-router';
function MyForm() {
const fetcher = useFetcher();
return (
<fetcher.Form method="post">
<input name="field" />
<button type="submit">Submit</button>
</fetcher.Form>
);
}Use the useModal hook from @neinteractiveliterature/litform:
import { useModal } from '@neinteractiveliterature/litform';
function MyComponent() {
const modal = useModal<{ userId: string }>();
return (
<>
<button onClick={() => modal.open({ userId: '123' })}>Open</button>
<MyModal
visible={modal.visible}
onClose={modal.close}
userId={modal.state?.userId}
/>
</>
);
}The codebase uses react-i18next for internationalization. Translation keys are defined in locales/en.json.
Always use t('some.key') instead of hardcoded strings in React components. Never use literal strings in JSX or passed as props. For JSX with mixed content (text + components), use the <Trans> component.
import { useTranslation } from 'react-i18next';
function MyComponent() {
const { t } = useTranslation();
return <h1>{t('myNamespace.myKey')}</h1>;
}Authentication tokens are managed through AuthenticityTokensContext:
import { useContext } from 'react';
import { AuthenticityTokensContext } from './AuthenticityTokensContext';
function MyComponent() {
const { tokens } = useContext(AuthenticityTokensContext);
// Use tokens for CSRF protection
}Common error: "Property 'instance' does not exist on type 'typeof AuthenticityTokensManager'"
Solution: Use AuthenticityTokensContext with useContext hook instead.
Use Luxon for date/time operations:
import { DateTime } from 'luxon';
import { useAppDateTimeFormat } from './TimeUtils';
function MyComponent() {
const format = useAppDateTimeFormat();
const formatted = format(DateTime.fromISO(isoString, { zone: timezoneName }), 'longWeekdayDateTimeWithZone');
}import formatMoney from './formatMoney';
const formattedPrice = formatMoney(priceInCents);