Frontend coding conventions
Contents
In this page you can find a collection of guidelines, style suggestions, and tips for making contributions to the codebase.
Two layers: Kea -> React
Our frontend webapp is written with Kea and React as two separate layers. Kea is used to organise the app's data for rendering (we call this the data or state layer), and React is used to render the computed state (this is the view or template layer).
We try to be very explicit about this separation, and avoid local React state wherever possible, with exceptions for the lib/ folder. Having all our data in one layer makes for code that's easier to test, and observe. Basically, getting your data layer right is hard enough. We aim to not make it harder by constraining your data to a DOM-style hierarchy.
Hence the explicit separation between the data and view layers.
General tips
- The
tracing-ui-v2feature flag replaces the tracing scene with aTracing UI v2placeholder for targeted testing. When the flag is off or unavailable, the existing tracing UI remains unchanged. - Think data first: get your mental model of the data flowing through the app right, and then everything else will be simpler.
- Be practical, yet remember that you are balancing speed of delivery with ease of maintainability. If you have to choose: code should be easier to understand than it was to write.
Do-s & Don't-s
- General
- Write all new code with TypeScript and proper typing. See Type system guide for guidance on generated vs handwritten types.
- Write your frontend data handling code first, and write it in a Kea
logic. - Don't use
useStateoruseEffectto store local state. It's false convenience. Take the extra 3 minutes and change it to alogicearly on in the development. - Logics still have a tiny initialization cost. Hence this rule doesn't apply to library components in the
lib/folder, which might be rendered hundreds of times on a page with different sets of data. Still feel free to write a logic for a complicatedlib/component when needed. - Use named exports (
export const DashboardMenu = () => <div />), and avoiddefaultexports.
- Naming things:
- Always look around the codebase for naming conventions, and follow the best practices of the environment (e.g. use
camelCasevariables in JS,snake_casein Python). - Use clear, yet functional names (
searchResultsvsdata). - Logics are camelCase (
dashboardLogic) - React components are PascalCase (
DashboardMenu). - Props for both logics and components are PascalCase and end with
Props(DashboardLogicProps&DashboardMenuProps) - Name the
.tsfile according to its main export:DashboardMenu.tsorDashboardMenu.tsxordashboardLogic.tsorDashboard.scss. Pay attention to the case. - Avoid
index.ts,styles.css, and other generic names, even if this is the only file in a directory.
- Always look around the codebase for naming conventions, and follow the best practices of the environment (e.g. use
- Component structure & reuse
- One component per file — a file exports the component its name promises. Avoid re-export shims and barrel files: every symbol should have exactly one import path, and moving a symbol means updating its consumers, not leaving a compatibility stub.
- Reach for an existing design-system component (Lemon/quill) before hand-rolling markup — reuse is how new UI stays on-brand. When you genuinely need a custom component, build it from the system's tokens and primitives and match the surrounding scene's density; avoid the generic AI-generated look (purple gradients, glassmorphism, gradient text, icon-tile card grids, decorative motion).
- Before building new UI, read a few comparable scenes or components and model yours on the ones that follow these conventions. The codebase contains legacy that predates them — an existing violation is not license to repeat it. Conventions outrank precedent, and compliant precedent outranks invention.
- Extract a shared component once the same shape appears in several places and the call sites read as content, not markup. Keep new generics next to the feature that uses them, and promote to
lib/only when a second feature needs them. Don't build wrappers with a single consumer, and don't add boolean variant props so one caller can switch half the component off — that's two components. - Interactive elements are real
<button>/<a>elements (LemonButtonrenders one) — neveronClickon a<div>. - With
focusBasedKeyboardNavigationenabled,LemonMenumoves focus between its trigger and menu items with the arrow keys, including after the menu reopens. Custom triggers must forwardonFocusandonKeyDownto preserve keyboard navigation and focus return. - Loading, empty, and error are three different screens. Never show an empty state from data that hasn't resolved yet — branch on the loading state first.
- In
createSetupDetectionLogic, returnnullwhen a setup check cannot answer. Like a thrown error, it preserves an existing setup status or shows the product scene if no answer exists yet. Returnunknownwhen the scene itself should handle the result, such as showing an access-denied screen. Successful checks can still advance setup when data arrives. - When renaming a feature, sweep code symbols completely — but analytics-facing strings (event names, property names and values,
data-attrvalues) and persisted keys are a frozen API: leave them as-is, with a comment noting they're pinned.
- Scenes
- Our app is built of scenes, managed through a scene router in
sceneLogic. - A scene is the smallest unit in the router and for code splitting. Usually we split scenes by resource type (dashboard, insight) and function (edit, index).
- Show available rows while other pages or sources load. Share concurrent requests for the same project and search across a scene and its sidebar.
- Load optional panes, hover cards, and alternative home screens with
lazyWithRetryandSuspense. Keep their data loaders in the component that needs them. A closed dialog or preview provider must not start requests for another pane. - Each scene (e.g. Dashboards) exports an object of type
SceneExport, containing the scene's rootlogicand its Reactcomponent. - The scene's logic is automatically mounted and receives the scene's URL params as props (via
paramsToProps). - Use
urlToActionandactionToUrlon the scene's logic to sync state with the URL. Try to only use them on the scene's logic, not in any deeper logics. - Logics mounted by React components through the view layer unmount when the component unmounts. Use
useAttachedLogic(dataNodeLogic(propsFromComponent), mySceneLogic())to attach a logic to the scene's logic so it persists until the scene's logic is unmounted, surviving React component remounts. - You can control what's shown on the tab via the
breadcrumbsselector in your scene's logic. The last breadcrumb controls the title and the icon, the one before that controls the back button. If there are more breadcrumbs, they will be ignored.
- Our app is built of scenes, managed through a scene router in
- Kea
- It's worth repeating: think of the data flow. Then work to simplify it. Derive as much state as possible via selectors, update the source via cascading actions, and avoid complex loops where a value triggers a subscription which calls an action which changes the value which triggers the subscription, ...
- Use
subscriptionsandpropsChangedsparingly, only if you can't find any other way. These have a high chance of leading to messy, cyclic or slow data flows. - Try to write your code such that you only use
urlToActionin your scene's logic (e.g.insightSceneLogic), and never deeper down in e.g.propertyFilterLogic. - Take the time and read through the Kea docs until you can explain how all the various operations (actions, reducers, selectors, listeners, subscriptions, props, events, hooks, etc) work behind the scenes. It's worth knowing your tools.
- CSS
- We use Tailwind CSS wherever possible
- Where it's not possible
- We use regular SCSS files for styling to keep things simple and maintainable in the long run, as opposed to supporting the CSS-in-JS flavour of the month.
- Inside
MyBlogComponent.tsximportMyBlogComponent.scss - Namespace all your CSS rules under globally unique classes that match the component's name and case, for example
.DashboardMenu { put everything here } - We loosely follow BEM conventions. If an element can't be namespaced inside a container class (e.g. modals that break out of the containing DOM element), use BEM style names like
.DashboardMenu__modalto keep things namespaced.
- Keep an eye out for custom styles in SCSS files that can be easily replaced with Tailwind classes and replace them with Tailwind when you see them
- Testing
- Before adding a test, make sure it earns its place and sits as low on the test pyramid as it can — the value/cost rubric in Backend coding conventions › Testing is language-agnostic.
- Write logic tests for all logic files.
- react testing library tests are particularly useful for components with complex interactions or to guide future humans or agents when they're changing components without full context of the uses and edge cases
- Add all new presentational elements and scenes to our storybook. Run
pnpm storybooklocally. - In web Storybook play functions, import
withinandwaitForfrom@testing-library/dom. The preview configures their async timeout;storybook/testuses a separate default. - A story must render the same picture on every run. See Deterministic stories.
Deterministic stories
By default a story renders a light and a dark visual review snapshot on every run that selects it (testOptions.skipLightMode and skipDarkMode turn one off).
A story that renders two different pictures for the same code blocks unrelated PRs, collects tolerations, and ends up quarantined.
Every pattern below caused a real quarantine. Each one has a fix that removes the race instead of hiding it.
What the runner already does
common/storybook/.storybook/test-runner.ts turns off animations and transitions, makes lazy images eager and waits for them to decode, preloads fonts, waits for a known loader to disappear, waits for network idle, and sets the theme on body[theme] before each snapshot.
Do not add your own waits for these.
The loader wait checks only the first element that matches a loader selector.
When a story shows more than one loader, set waitForSelector to content that renders after the last one.
The runner does not know when your own async content is done, when a timer changes the UI, or when a component measured itself at the wrong size.
Pin the clock
Relative text ("3 days ago", "2 years ago") changes as the wall clock moves, so a story that passes today fails next week on every branch at once.
Set parameters.mockDate, and write the dates in mock data relative to that date.
Wait for readiness, not for existence
testOptions.waitForSelector waits until the selector matches. Pick an element that only exists after the async work is done.
A container that renders before its data loads does not help.
- Monaco: wait for
.CodeEditor[data-editor-ready="true"]..monaco-editoralso matches Monaco's shared overflow root on<body>, which exists before any editor mounts. - A panel that loads its own data after the page loader is gone: wait for an element that renders only from the loaded data, for example a button that shows only when the list arrived empty.
Mock every request the story fires with mswDecorator. An unmocked request can resolve after the snapshot.
Give self-measuring content a fixed width
layout: 'padded' makes #storybook-root an inline block that shrink-wraps its content (frontend/src/styles/base.scss).
Content that measures its container when it mounts (Monaco, charts, React Flow) then takes whatever width the root had at that moment.
The first story in a file is the usual victim, because the layout class lands right before its snapshot.
Wrap the content in a fixed width (w-[42rem]), not a max-w-*.
Do not let the viewport set the height
The app shell has min-height: 100vh. When the viewport at capture time is taller than the content, the shell grows to the viewport height and the snapshot gets a blank strip below the content.
Let the shell hug the content in that story, for example with a decorator that sets .Navigation3000 { min-height: 0 }.
Wait for timers to settle
UI that changes on a timer (player controls that hide after 1.5 seconds, toasts, auto-collapsing panels) gives a different picture depending on when the snapshot fires.
In the play function, wait for the settled state, not the first state.
For example, wait until the controls are visible and then until they are hidden again.
Read the theme from the DOM
The runner switches the theme by setting body[theme]. A component that reads a logic value such as isDarkModeOn can lag one render behind and draw the first frame in the wrong theme.
Read document.body.getAttribute('theme'), or a hook that reads it.
Do not chain layout on previous state
A computation that uses the current state to produce the next one (for example React Flow fitView padding computed from the current zoom) lands on a different result depending on how often a resize observer fires.
Compute from the target state (the zoom the fit lands on), and clamp it to the same limits the library uses.
Canvas charts
A resize resets a canvas bitmap, and the redraw runs on the next animation frame. If the chart region changes size while the page settles (for example because a footer wraps after the chart width changes), the snapshot can catch an empty canvas. Keep chart containers at a fixed size in stories, and avoid content around the chart whose height depends on the chart width.
Find the variant before you fix it
Look at both pictures before you change anything.
The Flakiness tab in visual review lists the stories that flaked on the default branch, and each run shows the baseline and the variant image.
The diff region usually names the cause: a strip at the bottom is a height race, an empty chart is a canvas reset, a shifted block is a width race, a skeleton is a late load.
Some "flakes" are a stale or wrong baseline. Compare the variant with the git history of frontend/snapshots.yml before you change the story.
Sync note: This file is also copied to posthog/posthog/.claude/commands/conventions.md for Claude Code. When updating this file, please also update the copy there.