A React Native app for searching GitHub repositories and inspecting them — built as a take-home assignment, with the emphasis on the things that decide whether an app like this survives contact with real users: scroll performance, honest loading/error states, a typed boundary against the API, and a structure that a team can keep extending.
| Search | Results | Details |
|---|---|---|
![]() |
![]() |
![]() |
| Dark list | Dark details | Offline (cached) |
|---|---|---|
![]() |
![]() |
![]() |
All screenshots are from the release build on a Pixel 9 emulator (API 36).
yarn install
yarn android # or: yarn iosFor iOS, install pods first:
cd ios && bundle install && bundle exec pod install && cd ..
yarn iosOther scripts:
yarn verify # typecheck + lint + unit tests
yarn typecheck # tsc --noEmit (strict, noUncheckedIndexedAccess)
yarn test # jest
yarn apk:release # assembles android/app/build/outputs/apk/release/app-release.apkDownload the APK: see the latest release. It is a universal
(all-ABI) release build, signed with the template's debug keystore — deliberately, because a
real signing key does not belong in a public repository. Android will warn about an unknown
developer on install; yarn apk:release reproduces it byte-for-byte from source.
The app is unauthenticated by default, which GitHub limits to 10 search requests per
minute. If you hit the limit while exploring, the app tells you and counts down to the
reset. To raise it to 30/minute, put a classic PAT with no scopes in
src/shared/config/index.ts (githubToken). It is
deliberately not read from a committed .env.
| Requirement | Where |
|---|---|
| Search repos by keyword | SearchScreen, debounced 400 ms |
| Scrollable results (avatar, name, description, stars, language, updated) | RepositoryListItem |
| Detail screen (owner, stats, full description) | RepositoryDetailsScreen |
search/repositories?q={query}&per_page=100 |
repositories.ts |
TypeScript strict, no any |
tsconfig.json — strict, noUncheckedIndexedAccess, noImplicitOverride, noUnusedLocals |
| Infinite scroll | useRepositorySearch |
| Dark mode | ThemeProvider — system / light / dark, persisted |
| Offline support | Persisted query cache + NetInfo bridge (queryClient, networkBridge) |
Beyond the brief: sort selector (best match / stars / updated / forks), recent searches, pull-to-refresh, rate-limit countdown, skeleton loaders, empty/error/offline states, accessibility labels on every interactive element, and 32 unit tests.
| Choice | Why this one |
|---|---|
| React Native 0.87, New Architecture, Hermes | Fabric/TurboModules are the default now; Hermes gives the faster startup and lower memory that matter more on Android mid-range devices than raw JS throughput. |
| TanStack Query v5 | Search-with-pagination is a server-state problem, not a client-state one. Query gives request deduplication, cancellation, caching, useInfiniteQuery, and cache persistence out of the box. Redux/Zustand here would mean hand-writing all of that — and there is no meaningful client state left to manage: two useStates (query text, sort) cover it. |
| FlashList v2 | Recycles views instead of keeping every row mounted, which is the difference between smooth and janky on a 1,000-row list. v2 measures rows itself, so no estimatedItemSize guessing. |
| React Navigation 7 (native stack) | Native-stack transitions run on the platform animator, off the JS thread — a JS-driven stack drops frames exactly when the user is also waiting for data. |
| zod | Validates GitHub's payload at the boundary and derives the DTO types, so "strongly typed" means the types are checked at runtime, not asserted with as. Also strips unused fields, keeping the persisted cache small. |
| react-native-view-shot | The only way to show two theme states at once for the reveal transition; used for ~400 ms per switch and never on a hot path. |
| react-native-svg + hand-written icons | A dozen glyphs do not justify an icon font and its native linking; SVG stays crisp at any density and is themeable. With one measured exception: SVG views are too expensive inside recycled list rows, so rows use text glyphs — see the jank investigation. |
| AsyncStorage | Only used for cache persistence, the theme preference, and recent searches — all off the interaction path, where its performance profile is irrelevant. MMKV would be the pick if storage were on a hot path. |
| No styling library | Tokens + StyleSheet + a cached makeStyles factory is zero-dependency and zero-runtime. A styled-components-style library would add per-render style computation to every one of those recycled rows. |
src/
├── app/ # Composition root: providers, navigation, query client
│ ├── navigation/ # Typed RootStackParamList — no untyped route params anywhere
│ ├── providers/ # ErrorBoundary
│ └── query/ # Cache policy, persistence, NetInfo/AppState bridge
├── features/
│ ├── search/ # screens / components / hooks for one user-facing capability
│ └── repo-details/
└── shared/
├── api/ # client (timeout, abort, error normalisation) + github/ (schemas, mappers, keys)
├── config/ # build-time constants
├── lib/ # framework-free helpers: format, debounce, connectivity
├── theme/ # tokens, ThemeProvider, makeStyles
└── ui/ # design-system primitives (Text, Avatar, Badge, Skeleton, …)
Code style: no inline commentary. Names, types and small functions are expected to carry the "what"; the "why" for every non-obvious decision lives in this README instead of being scattered across files where it goes stale.
Three rules keep this navigable as it grows:
- Dependencies point inwards.
features/*may useshared/*;shared/*never imports from a feature; features never import from each other. Adding a "starred repos" feature means adding a folder, not editing existing ones. - The API shape stops at the boundary.
snake_caseDTOs exist only insideshared/api/github. Everything above it consumes theRepositorydomain model, so a GitHub API change is a one-file change inmappers.ts. - Screens compose; hooks decide.
useRepositorySearchowns paging, the 1,000-result ceiling, deduplication and the flags for each state;SearchScreenpicks one of four states and renders. That is why the paging logic is unit-tested without rendering a list.
SearchField → debounce 400 ms → useRepositorySearch (useInfiniteQuery)
↓ queryFn({ signal })
searchRepositories() → request() → fetch
↓ ↓ zod
SearchPage (domain model) ← toRepository()
↓
FlashList → RepositoryListItem
↓ onPress (row passed as route param)
RepositoryDetailsScreen → useRepository
↑ placeholderData = the tapped row
docs/demo.mp4 — 30 s recording of everything below.
| What | How it behaves |
|---|---|
| Theme switch | A circular reveal of the new theme expands from the toggle across the screen, with real content — not a flat colour — visible the whole time. See how it works below. |
| Theme icon | Sun and moon are both mounted; they cross-fade, counter-rotate and scale into each other, driven by the same progress value as the reveal so icon and screen move together. |
| Repo title, list → detail | The tapped row's title flies to the detail heading, growing from 17 pt to 28 pt, in a layer above the navigator (HeroTitleProvider). The detail screen fades in behind it, so the title is the element that carries the motion. |
| Detail content | Reveals in a 55 ms stagger — description, topics, stats, owner, metadata, actions (Reveal). |
| Detail header | Fades and lifts as you scroll, driven by Animated.event(…, { useNativeDriver: true }) — no JS work per frame. |
| Sort change | The newly selected chip springs 0.94→1, and the list dims to 45 % while the new sort loads, so a slow request looks intentional instead of frozen. |
| Offline banner | Animates its own height open/closed instead of popping in and shoving the list down. |
| Search field | Focus ring fades in; the clear button and the in-flight spinner cross-fade rather than swapping. |
| Row press | Native Android ripple (android_ripple) plus a pressed background — zero extra views per row. |
| Empty / error states | Fade and lift in on appearance. |
A true reveal needs two visual states of the same content on screen at once, which a single
React tree cannot provide — one ThemeProvider means one palette. So the transition uses
snapshots (react-native-view-shot):
- On
onPressIn, capture the current screen — starting early means the reveal begins on release instead of after a capture round trip. - Cover the screen with that snapshot. Nothing appears to change.
- Swap the palette underneath, let it paint, and capture again.
- Grow a circular window containing the new snapshot from the toggle outward, with the
image counter-scaled (
transformOrigin+Animated.divide) so its content stays pinned while the window expands. Both layers are real content, so nothing is ever hidden behind a flat fill. - Drop both overlays; the live tree underneath is already in the new theme.
The toggle icon is lifted into that overlay layer from onPressIn — before either snapshot is
taken — and shares one Animated.Value with the real control (which hides itself meanwhile).
Two reasons: underneath the snapshots its morph is invisible and the user only sees the
endpoints, and because it is lifted before the captures, neither snapshot contains an icon,
so nothing has to be masked over. Every animated property is opacity or transform, so the
whole transition runs on the native driver.
Three things I got wrong on the way, all worth keeping in mind: growing the window with the
outgoing snapshot inside makes the end of the transition blurry (the magnified image is
largest when the window is smallest); a freshly mounted Image on Android fades in over 300 ms
by default, which silently blended the two snapshot layers until fadeDuration={0}; and an
earlier version masked the snapshots' baked-in icons with a small patch of the incoming
background colour, which read as a stray square behind the icon for the first frames — lifting
the control before the captures removes the need for it.
- RN's own
Animated, not Reanimated. Reanimated 4.5 declares support forreact-native 0.83 – 0.86; on 0.87 only nightlies exist, and a nightly native dependency is not something I would ship. Everything here is opacity or transform, which the built-in driver runs natively — so the dependency wasn't earning its cost. Gesture-driven motion or per-colour interpolation would change that answer. - No colour tweening. Animating theme colours would mean animated styles on every node, including recycled rows. The snapshot reveal gets a better result from two images.
- No entering animations inside the list. Item-level reveals fight view recycling: a recycled cell re-triggers its entrance and rows flicker mid-scroll. The list fades in as a whole instead.
useReducedMotioneverywhere. With the OS setting on, every animation — including the reveal and the hero title — jumps to its final state. Motion is polish, never a gate on content appearing.- Hero is forward-only. Going back does not fly the title home; the reverse case needs the list row's position while the list is off-screen, which is a bigger piece of work than it looks. Documented rather than half-built.
Scroll performance after all of it, measured on a freshly booted emulator:
0.62–0.74 % janky frames, p50 17 ms, p90 18 ms, 0 slow UI-thread frames — unchanged from
the pre-animation baseline (docs/performance/).
What was done, and the reasoning:
List rendering
- FlashList v2 with view recycling;
drawDistance={800}renders ~1.5 screens ahead so scrolling never waits on cell creation, while keeping the recycle pool small. - Rows are
memoised and receive a stableonPress; TanStack Query's structural sharing keeps unchanged items referentially identical across refetches, so a refetch that changes one repository re-renders one row, not 100. - No shadows, gradients or
Animatedin rows — recycling a row is a flat prop update. - Separators and footers are hoisted/memoised so they are not rebuilt per scroll frame.
Images — avatars are requested at the exact rendered size (?s=132 for a 44 pt row),
so a page of 100 rows pulls ~4 KB thumbnails instead of 460 px PNGs. A failed load degrades to
an owner initial, not a broken image box. Worth noting against intuition: when jank did show
up, images turned out not to be the cause (see below).
Network
- 400 ms debounce plus in-flight cancellation via the query
signal: typing "react-native" issues one request, not twelve. staleTime: 5 min— going back from a detail screen, or flipping sort back and forth, is served from cache with zero requests (also the main defence against the 10 req/min limit).- Pages are fetched at
onEndReachedThreshold={0.6}, andgetNextPageParamstops at GitHub's 1,000-result ceiling so the app never fires the request that would 422.
Styling — makeStyles caches one StyleSheet per theme in a WeakMap, so mounting the
201st row does not build a 201st stylesheet, and switching to dark mode reuses the sheet it
built the first time.
Startup — Hermes bytecode, no work at module scope beyond wiring NetInfo into the query client, and the cache rehydrates from AsyncStorage asynchronously, so a cold start renders the search screen without waiting on storage.
The interesting part of this build was not the list of optimisations above — it was the one that turned out to be wrong.
Measuring a deliberately harsh case (18 back-to-back flings while a new 100-item page and its avatars arrive) showed 22–33 % of frames over budget, p50 34 ms, p90 65–81 ms, with 50–59 "slow UI thread" frames. Three builds isolated it:
| Build | Janky frames | p50 |
|---|---|---|
| As written (3 SVG icons per row) | 22–33 % | 34–36 ms |
| Avatars replaced with plain views, icons kept | 20–24 % | 34–38 ms |
| Icons removed | 0.3–0.7 % | 17 ms |
| Icons as text glyphs, avatars back on | 0.3–0.7 % | 17 ms |
Images were not the problem; mounting three react-native-svg views per row during recycling
was all of it. Svg now stays on the detail screen — a handful of instances, never recycled —
while rows use a Text glyph for the star and chevron, and render fork counts as
25.2K forks (the ⑂ codepoint is ambiguous at 13 px and not safe across OEM fonts).
Two things I would have got wrong without measuring: that avatars were the likely culprit, and
that "hand-rolled SVG icons" — justified in the stack table above as the lightweight choice —
is only lightweight outside a recycler. Full method, raw captures and the failed
fadeDuration={0} attempt are in docs/performance/.
Release build (R8 + resource shrinking on, Hermes), Pixel 9 emulator, API 36, arm64:
| Metric | Result | How |
|---|---|---|
| Cold start (activity displayed) | 126–146 ms; 624 ms on the very first launch after install | adb shell am start -W, ActivityTaskManager: Displayed |
| Scroll, 18 flings across page boundaries | 0.3–0.7 % janky frames, p50 17 ms, p90 17–18 ms, p99 19 ms, 0 missed vsyncs, 0 slow UI-thread frames | dumpsys gfxinfo — raw captures in docs/performance/ |
| Scroll, steady state inside loaded rows | p50 16 ms, p90 17 ms, p99 21 ms, 0 missed vsyncs | same |
| Memory, 300 rows loaded + detail screen visited | 154 MB PSS | dumpsys meminfo |
| JS bundle | 1.94 MB minified JS → 2.25 MB Hermes bytecode in the APK | yarn bundle:visualize |
| Release APK (universal, 4 ABIs) | 70.6 MB | yarn apk:release |
Reproduce:
adb shell am start -W -n com.githubexplorer/.MainActivity
adb shell dumpsys gfxinfo com.githubexplorer reset # fling the list, then:
adb shell dumpsys gfxinfo com.githubexplorerTwo honest caveats. First, these come from an emulator on an M-series Mac — emulator numbers
flatter a real mid-range Android device, so treat them as a regression baseline, not as field
performance. Second, am start -W measures the activity's first frame; the JS content
follows within the same budget here because the first screen is the (cheap) idle state, so
read it as startup cost, not as a formal time-to-interactive.
The 70.6 MB APK is a universal build carrying four ABIs so it installs on any device or emulator. A real release would ship an AAB (or per-ABI splits), which lands around 25 MB per device — the size here is a deliberate convenience trade-off for the reviewer, not the app's actual footprint.
Every failure is normalised into one ApiError with a
discriminated kind (offline, network, timeout, rate-limit, not-found,
invalid-query, server, parse). Both the retry policy and the user-facing copy switch
on that field, so behaviour is consistent and testable:
- Rate limit — parsed from
x-ratelimit-reset/retry-after; the UI counts down and disables retry until the reset instead of hammering a request that cannot succeed. - Offline — queries pause (NetInfo →
onlineManager) and resume on reconnect; cached results stay browsable behind an offline banner. - Failure while paginating — reported in the list footer so loaded results stay usable; only a failed first page takes over the screen.
- Detail screen with a stale cache — shows the cached repository plus a "could not refresh" note, rather than replacing content the user can still read with an error.
- Render bugs — an
ErrorBoundaryat the root offers recovery instead of a white screen.
yarn test32 tests over the parts where a regression would be invisible in a screenshot: the API layer (query construction, DTO→domain mapping, rate-limit/404/parse/network classification), the pagination hook (page chaining, the 1,000-result ceiling, cross-page deduplication, first-page failure), the formatting helpers, and the list row (content, press payload, archived/fork badges, missing description).
With more than the assignment's time budget, in priority order:
- Measure on real hardware. Emulator numbers are a relative baseline, not evidence — and the icon finding above shows how much the answer depends on measurement. I would re-run it on a Pixel 6a and a low-end device with Perfetto and the React Native DevTools profiler, and add a Reassure test so a PR that regresses list render cost fails CI instead of being caught by hand.
- Detail screen depth. README rendering (markdown + syntax highlighting), contributors, release/tag info, and language breakdown — each is another endpoint, so it also needs a per-section loading/error story rather than one screen-level spinner.
- Search qualifiers as UI. GitHub's
language:,stars:>1000,pushed:>2026-01-01are powerful and undiscoverable as raw text; a filter sheet that composes the query string would be the highest-value UX addition. - Auth. Device-flow OAuth for 30 req/min and starring from the app, with the token in Keychain/Keystore rather than in a source constant.
- E2E, and CI that publishes. Maestro flows for search → detail → offline. CI already
runs
yarn verifyand builds the APK as an artifact (.github/workflows/ci.yml); the next step is attaching that APK to a tagged release so the download is machine-built rather than hand-built. - Observability. Sentry for crashes and a startup/interaction trace, so performance claims after release come from field data.
- Small ones. Shared-element transition from row avatar to detail header; a
"back to top" affordance after long scrolls;
react-native-configfor the token; snapshot-free visual regression via Maestro screenshots.
- The tapped row travels to the detail screen as a navigation param to enable instant paint.
It is plain JSON and small, but a deep link (or state restoration) legitimately arrives
without it, which is why
fullNamealone is sufficient and the preview is optional. watchers_countfrom the search endpoint is a copy of the star count (a long-standing GitHub quirk), so the detail screen shows—for watchers until the full record loads rather than a number that is wrong.- No i18n. Copy is inline English; the formatting helpers already funnel through
Intl, so the swap is contained, but a real app would extract strings from the start.





