Skip to content

Repository files navigation

GitHub Repos Explorer

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
Idle state Results Details
Dark list Dark details Offline (cached)
Dark mode list Dark mode details Offline

All screenshots are from the release build on a Pixel 9 emulator (API 36).

Getting started

yarn install
yarn android      # or: yarn ios

For iOS, install pods first:

cd ios && bundle install && bundle exec pod install && cd ..
yarn ios

Other 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.apk

Download 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.

GitHub rate limits

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.

Requirements coverage

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.jsonstrict, 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.

Stack, and why

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.

Architecture

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:

  1. Dependencies point inwards. features/* may use shared/*; 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.
  2. The API shape stops at the boundary. snake_case DTOs exist only inside shared/api/github. Everything above it consumes the Repository domain model, so a GitHub API change is a one-file change in mappers.ts.
  3. Screens compose; hooks decide. useRepositorySearch owns paging, the 1,000-result ceiling, deduplication and the flags for each state; SearchScreen picks one of four states and renders. That is why the paging logic is unit-tested without rendering a list.

Data flow

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

Motion

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.

The theme reveal

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):

  1. On onPressIn, capture the current screen — starting early means the reveal begins on release instead of after a capture round trip.
  2. Cover the screen with that snapshot. Nothing appears to change.
  3. Swap the palette underneath, let it paint, and capture again.
  4. 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.
  5. 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.

Decisions

  1. RN's own Animated, not Reanimated. Reanimated 4.5 declares support for react-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.
  2. 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.
  3. 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.
  4. useReducedMotion everywhere. 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.
  5. 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/).

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 stable onPress; 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 Animated in 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}, and getNextPageParam stops at GitHub's 1,000-result ceiling so the app never fires the request that would 422.

StylingmakeStyles 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 jank investigation: SVG icons in recycled rows

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).

Frame-time distribution before and after

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/.

Measurements

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.githubexplorer

Two 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.

Error handling

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 ErrorBoundary at the root offers recovery instead of a white screen.

Tests

yarn test

32 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).

What I would do next

With more than the assignment's time budget, in priority order:

  1. 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.
  2. 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.
  3. Search qualifiers as UI. GitHub's language:, stars:>1000, pushed:>2026-01-01 are powerful and undiscoverable as raw text; a filter sheet that composes the query string would be the highest-value UX addition.
  4. 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.
  5. E2E, and CI that publishes. Maestro flows for search → detail → offline. CI already runs yarn verify and 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.
  6. Observability. Sentry for crashes and a startup/interaction trace, so performance claims after release come from field data.
  7. Small ones. Shared-element transition from row avatar to detail header; a "back to top" affordance after long scrolls; react-native-config for the token; snapshot-free visual regression via Maestro screenshots.

Known trade-offs

  • 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 fullName alone is sufficient and the preview is optional.
  • watchers_count from 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.

About

React Native 0.87 + TypeScript app for searching GitHub repositories — FlashList, TanStack Query, offline cache, animated theme reveal

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages