@axonpack/expo-devtools
v2.5.4
Published
Development Debugging tool for React Native
Maintainers
Readme
@axonpack/expo-devtools
Browser-style devtools that live inside your React Native or Expo app.
Tap a floating button for four tabs on the device itself: Network (every request, resendable, with throttling), Console (every log, plus a prompt that answers), Performance (frame rate, memory, the moments the app froze) and Storage (every key you've saved — search it, edit it, import and export it). No desktop debugger, no cable, and nothing captured until you switch it on.
Documentation · Reference · Changelog
Installation
npx expo install @axonpack/expo-devtools react-native-safe-area-context react-native-webview expo-clipboardreact-native-safe-area-context, react-native-webview and expo-clipboard are peer dependencies —
your app supplies them, so each resolves to the version your Expo SDK ships rather than one this
package pins. The overlay lays itself out inside the safe area, response previews render in a WebView,
and every Copy button uses the clipboard.
Quick start
Two things have to happen: init() runs once at startup, and <DevtoolsOverlay /> is mounted once
at the root. Nothing else: no config plugin, no app.json changes, no native code to write.
1. Create the client. One shared instance the rest of your app imports, plus one flag deciding whether it runs at all:
// devtools.ts
import { createDevtoolsClient } from '@axonpack/expo-devtools';
export const DEVTOOLS_ENABLED = process.env.EXPO_PUBLIC_APP_ENV !== 'prod';
export const devtools = createDevtoolsClient();Set EXPO_PUBLIC_APP_ENV=prod for your production builds (in eas.json, or a .env file) and leave it
unset everywhere else. Use __DEV__ instead if a dev/release split is all you need.
2. Wire it up. Copy whichever matches your app. You only need one of these.
The root layout is the place. devtools.init() goes at module scope, outside the component, so the
fetch/console patches are installed before the first screen renders.
// app/_layout.tsx
import { Stack } from 'expo-router';
import { DevtoolsOverlay } from '@axonpack/expo-devtools';
import { devtools, DEVTOOLS_ENABLED } from '../devtools';
if (DEVTOOLS_ENABLED) devtools.init();
export default function RootLayout() {
return (
<>
<Stack />
{DEVTOOLS_ENABLED && <DevtoolsOverlay />}
</>
);
}init() goes in the entry file, before the app is registered. The overlay goes in your root component.
// index.ts
import { registerRootComponent } from 'expo';
import App from './App';
import { devtools, DEVTOOLS_ENABLED } from './devtools';
if (DEVTOOLS_ENABLED) devtools.init();
registerRootComponent(App);// App.tsx
import { DevtoolsOverlay } from '@axonpack/expo-devtools';
import { DEVTOOLS_ENABLED } from './devtools';
export default function App() {
return (
<>
<YourApp />
{DEVTOOLS_ENABLED && <DevtoolsOverlay />}
</>
);
}That's it. Drag the button anywhere on screen, tap it to open the panel, and the Network and Console tabs are already recording. The Storage tab is empty until you tell it which stores you use — see Storage. The panel reopens on whichever tab you last had open, for as long as the app is running.
A few things that trip people up:
- Mount the overlay exactly once. The root is the place to do it, because one mount there covers every route: the panel opens as a modal on top of whichever screen is showing, so nested Tabs and Drawer layouts are already covered and must not mount their own. A second mount gives you a second button.
init()runs exactly once too, at module scope rather than in auseEffect. Anything that fires before an effect would run, such as requests during module evaluation or logs at import time, is missed otherwise.- Performance starts paused. Measuring isn't free, so press its record button when you want it. The other two tabs record from launch.
- Expo Go works. A handful of readings come from this package's native module and go quiet there: main-thread frame rate, app and device memory, storage, the Debug tab's main-thread controls, and native crash capture. Everything else, including all of Network and Console, behaves the same. Use a development build for the full set.
- In-app browser pages need two extra props on the
<WebView>itself. See Capturing inside an in-app browser. init()is the guard.<DevtoolsOverlay />draws nothing untilinit()has brought the panel up, so an unguarded mount in a release build is harmless rather than a button over empty lists. Crash reports still surface, because that is the one subsystem meant to run in production.
Optional: starting before Expo Router
Skip this unless you need it. Step 2 is enough for normal use.
The root layout runs after Expo Router's own entry file, so requests and logs from that window are missed,
and the startup breakdown's App setup phase starts later than the app really did. You can move init()
ahead of Expo Router by owning the entry file yourself.
Point main at your own file:
// package.json
{ "main": "index.js" }Then have that file call init() before handing control to Expo Router. The import order is the whole
point, so keep init() in a separate module rather than calling it inline: an import is hoisted above
statements in the same file, which would put expo-router/entry first anyway.
// index.js
import './devtools-init'; // a module whose only job is `devtools.init()`
import 'expo-router/entry';Now remove the devtools.init() line from app/_layout.tsx, keeping <DevtoolsOverlay /> there. The
overlay still belongs in the root layout; only the init() call moves.
The launcher button
Nothing has to be configured: <DevtoolsOverlay /> on its own gives you the bug glyph on a blue circle.
Everything about its appearance is a prop, since that's where you mount it.
| Prop | Default | What it does |
| --------------- | ----------- | ---------------------------------------------------------------------------------- |
| iconComponent | none | Renders in place of the built-in glyph. Given the resolved size; colour is yours |
| size | 44 | Diameter of the button, in dp |
| color | accent blue | Button fill |
| iconColor | white | The built-in glyph only; an iconComponent colours itself |
<DevtoolsOverlay
iconComponent={({ size }) => <MyLogo width={size} height={size} />}
size={56}
color="#111827"
/>A size under 44 still gets a 44dp touch area through hitSlop, so a small button stays as easy to hit
as it looks, and however big you make it, the drag stays inside the screen.
[!TIP]
colorand the default glyph have to work together: a pale button needsiconColorset, or the white glyph vanishes into it.
Features
Debugging on a real device usually means plugging into a laptop, or losing the thing you were trying to reproduce the moment you reach for a menu. This puts the tools where the bug is.
Network
Every request lands as a row: the method, the status, how long it took, and when. Underneath, the short name and the full URL, plus badges for the kind of response, where the request came from, and how big it was. A request still in flight shows an amber PENDING so you can tell "waiting" from "finished".
Finding one request among hundreds. Search the text, then narrow with the chips: type (Fetch/XHR, JS,
Img, Media, Other), status (2xx, 4xx, Failed, Pending), method, or source. The status, method and source
chips are built from what you've actually captured, so they only ever offer real options, and method and
source take more than one at a time — two clients side by side, or GET and POST together. Status also
takes an expression when a band is not the question: >= 400, 200-299, or one exact code. Search matches
light up in the list, and the box carries the three switches you expect from an editor: match case,
whole word, and regex. Invert flips the whole filter — every chip, not just the text — and
Clear resets all of it in one press. Under More filters: a size and a duration range (20kb,
1.5s — the units you'd say out loud), show only what is still in flight, show only what one of your
override rules answered, and the toggles that hide data URLs or failed requests.
Testing a bad connection. Pick Slow 3G, Fast 3G, Fast 4G, Offline, or set your own speed and delay. It applies immediately, to your app's own requests and to in-app browser pages. You can also pretend to be an iPhone, an Android phone, a desktop browser, or Googlebot. Every captured request remembers the settings it ran under, so requests from before and after a change stay easy to tell apart.
Reading the room. Turn on the traffic graph to see request volume over time and tap a section to zoom the list to that moment. Turn on grouping to bundle rows by where they came from, with a count per group. Or switch to compact rows to fit more on screen. Sort by time, size, duration or status — the arrow in the toolbar flips the direction and says what pressing it would give you, so "which one is slow" is one tap rather than a read through two hundred rows.
Tapping a request
A panel slides up with everything captured, across a few tabs:
- Headers: what was sent and what came back, each value with its own copy button, plus the connection settings this request ran under.
- Payload: what you sent, as an explorable tree rather than a wall of text.
- Preview: the response pretty-printed and colour-coded; images and HTML render as a real preview.
- Response: the raw body, in full, never cut off.
- Timing: when it started and how long it took. It also tells you plainly that a DNS/TCP/TLS breakdown isn't available on-device, rather than showing numbers it can't measure.
Payload, Preview and Response share a search box that stays put while the body scrolls, with the same match case / whole word / regex switches, and every hit highlighted where it sits — in the JSON tree, in the syntax-coloured code, and in the raw body alike.
The ⋮ menu copies the URL, or the whole request as a ready-to-paste cURL command or fetch
snippet.
Try in sandbox opens the request as something you can edit: change the method, the URL, the query parameters, headers, cookies, auth, or the body, then Send and watch the real response come back. Handy for "does this break if the token is missing?" without touching your code.
Prefer to look at it later? Export opens the OS share sheet with the currently-filtered list as JSON,
named network-log-<timestamp>.json. Mail it to yourself, drop it in Slack, paste it into a bug report.
It uses React Native's own Share and nothing else, so there's no filesystem involved and no extra
dependency.
Console
Everything your app logs, on the device. Warnings sit on a yellow row, errors on a red one, and the toolbar keeps a running count of each so you can see at a glance whether anything went wrong while you weren't looking.
- Each thing you logged gets its own line, so a message and the object next to it don't run together. Objects and arrays start collapsed. Tap to open them up, level by level.
- Errors show their message on the row and the full stack when you tap it.
- The same message logged over and over becomes one row with a count, so a chatty screen doesn't bury everything else.
- Filter by level (each chip carries a live count) or by source, or search the text of every message — with match case, whole word and regex, and every match highlighted where it sits.
- The newest output stays in view automatically, and stops following if you scroll back to read something, with a button to jump back to the newest.
- Copy any line with one tap.
Running an expression
The > prompt at the bottom runs JavaScript on the device and shows you what came back. Your typed
command appears with a ›, the answer with a ‹, and objects come back as the same explorable tree.
Something that returns a promise shows as pending and fills in when it settles, so
fetch(...).then((r) => r.json()) works as you'd expect.
- Names are suggested as you type, including the members of whatever object you're inside.
- Tap any command you ran earlier to load it straight back into the prompt.
- Two built-in helpers reach your app's own code in a development build:
$modules('auth')lists the files that are loaded, and$m('src/stores/auth')hands you one of them.
To reach your own objects by a short, stable name, pass them in:
createDevtoolsClient({
console: { context: { store, queryClient } },
});Your app's files are bundled as private closures, so nothing can reach an imported name on its own the way
a browser console reaches a page's variables. Anything you want to poke at by name, hand over in
context. It's also the only thing that works in a release build, where the file list above isn't
available.
[!IMPORTANT] The prompt is off in release builds by default, since it runs whatever is typed into it. Turn it on deliberately with
console: { repl: true }.
Performance
Live metrics on the device, no desktop profiler and no cable. The toolbar carries the record and clear buttons, then a chip per section. Only the section you're looking at is mounted, so the charts aren't re-rendering behind a list you're reading.
[!NOTE] Unlike the other two tabs, this one starts paused, because measuring isn't free. While it's paused nothing is measured at all: the instrumentation detaches rather than running and discarding, so leaving it off costs nothing. Pressing record attaches everything fresh and picks up whatever the platform still has buffered.
Statistics
- Frame rate: the JS thread from a frame-delta loop, and the main thread from a native display-link counter, on one chart. The gap between them is the reading that matters: a healthy JS line above a collapsed main-thread line is an app that feels frozen while every JS metric says it's fine. The main-thread figure needs a development build.
- JS heap: how much the JavaScript engine has allocated, with a sparkline of the last two minutes so you can watch it climb while you use the app.
- App memory: the whole process footprint, which is what the OS holds against you and what a user means by "memory". Routinely several times the JS heap, so the two are stacked as separate plots rather than letting one stand in for the other. Needs a development build.
- Device memory: how much RAM the phone has, and how much this app may still allocate, as a meter directly under the app's own footprint. On Android the available side is system-wide free memory; on iOS it's what the process can still claim before being killed. Needs a development build.
- Storage: total, used and free space on the data partition. Android only, for the App Store reason below. Needs a development build.
- Startup: process start to first render, split into native startup, bundle eval, app setup and first render. Measured by this package's own native module, so it works even where the platform's own markers are all null. Phase boundaries are this package's load points, not platform milestones, so they shift a little with import order. If the platform does report its markers, they're shown underneath as a second set.
The three lists
User timing: timings you name yourself, following the W3C User Timing signatures. The one metric here that can point at a specific piece of code, so it's the answer to a long task you can't explain:
devtools.mark('checkout'); await buildCart(); devtools.measure('checkout');measure(name, startOrOptions?, endMark?)takes a start mark name, or an options object withstart,end,durationanddetail, the same shapes the spec defines. Calls are also forwarded to the realperformance.mark/measure, so the entries exist on the platform timeline too. Nothing is observed from that timeline, which is why React's own internal measures never appear here.Interactions: anything that took longer than 100 ms from the event to the next paint. Each row also shows how long your handler itself held the JS thread: a small handler under a large total means the interaction was stuck behind something else rather than being slow itself. Durations come rounded to the nearest 8 ms, and nothing under 16 ms is ever reported.
Long tasks: anything that blocked the JS thread past the threshold (150 ms by default), newest first. A "long task" is one stretch of JavaScript that ran without yielding, so nothing else on that thread could happen meanwhile: no touches, no timers, no animation driven from JS. At 60 fps a frame is 16.7 ms, so 150 ms is about nine frames lost; past ~200 ms it reads as a freeze. Drop
performance.longTaskThresholdMsto 50 if you want to see the smaller ones too.
What it deliberately doesn't show
This tab is honest about the difference between what it can actually measure and what you probably want to know:
- Storage is Android-only. Nothing here asks for a permission on either platform, and nothing makes
your App Store submission harder, which is why iOS storage is missing.
StatFson Android needs no permission and no manifest entry, but iOS'ssystemFreeSizeis one of Apple's required-reason APIs: no prompt, but it obliges a privacy-manifest declaration at submission, and a library reading it risks pushing that onto every app that embeds it. - No "% of heap limit" gauge. Hermes doesn't report a heap-size limit, so the denominator would have to be invented.
- No heap snapshots or flame charts. Those come from the CDP
HeapProfiler/Profilerdomains over the inspector socket, driven from outside the app, and a multi-megabyte snapshot isn't something you'd browse on a phone anyway. - Long tasks name no culprit. React Native's
PerformanceLongTaskTimingreturns a permanently emptyattributionarray, the web API's mechanism for reporting which code was responsible, so a row can tell you a task blocked the thread for 180 ms, but never that it was your list render. Use it to find when to look, then correlate against what the app was doing. - Some metrics depend on the platform. Long tasks and the platform's own startup markers only appear if the native side implements them, which varies by platform and React Native version. When they're missing the tab says so rather than showing zeros.
- Some entries never reach the list. The platform keeps its own buffer and discards entries once it overflows, telling us only how many went missing. When that happens the list says so rather than presenting what survived as the whole picture.
Storage
Every key you've saved, on the device, without a console.log(await AsyncStorage.getAllKeys()).
This tab is the one that can't find anything by itself. A key-value store is a separate install with its own native code, and this package deliberately depends on none of them — so you hand over the stores you already use, once, where you create the client:
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as SecureStore from 'expo-secure-store';
import { createMMKV } from 'react-native-mmkv';
import {
createDevtoolsClient,
asyncStorageAdapter,
mmkvAdapter,
secureStoreAdapter,
defineStorageAdapter,
} from '@axonpack/expo-devtools';
const mmkv = createMMKV();
export const devtools = createDevtoolsClient({
storage: {
adapters: [
asyncStorageAdapter({ driver: AsyncStorage }),
mmkvAdapter({ driver: mmkv }),
// SecureStore can't list its own keys, so you name the ones worth watching.
// A function works too, if your app keeps its own list — it's read on every refresh.
secureStoreAdapter({ driver: SecureStore, keys: ['session'] }),
// Anything else — your own cache, a wrapper, an in-memory store.
defineStorageAdapter({
name: 'My cache',
kind: 'sync',
blacklist: /^secret\./, // keys you'd rather the panel never see
getAllKeys: () => cache.keys(),
getItem: (key) => cache.get(key) ?? null,
setItem: (key, text) => cache.set(key, text),
removeItem: (key) => cache.remove(key),
}),
],
},
});Then the toolbar gets a dropdown to switch between your stores, and each key a row with its type, size and value:
- Search keys, values or both, with match case / whole word / regex, and matches highlighted in place.
- Filter by value type — Object, Array, String, Number, Boolean, Binary, Empty, Missing — with counts, or narrow to JSON only and hide empty values.
- Sort by key, size or type, ascending or descending, and group by namespace: the
auth:token,cache/user/1,settings.themeprefixes your keys already use, which no store knows about. - Tap a key for its value in the same expandable JSON tree the Network tab uses, the raw characters exactly as stored, an editor, and an Info tab. Copy the key, the value, or the pair as JSON.
- Edit a value and delete a key, one at a time, with a confirmation on delete.
- Add a key the store doesn't have yet: you pick the name, the type and the value. A key that's already there is refused rather than quietly overwritten.
- Export the filtered keys of a store as JSON through the share sheet. The file carries a version, so it can be read back later.
- Import one of those files: paste it in and you're told how many keys are new, how many would be overwritten, how many already hold that value and how many are skipped — before anything is written.
Whether a store can be edited or deleted from is derived from what you handed over: register it without a
setItem and the editor says so. Add and Import only appear for a store that can be written to.
storage: { readOnly: true }, or readOnly on one adapter, makes that explicit.
Two more options on an adapter are worth knowing:
blacklist— aRegExpor a function. A key it matches is never listed, never read and never written, so its value never reaches the panel at all. The tab says a blacklist is set, but not how many keys it hid.supportedTypes— what the store can really hold. AsyncStorage and SecureStore hand back a string whatever went in, so they declare['string']for you; MMKV takes all four. The Add-key sheet offers only these, so you can't write something the store would flatten.
Debug
Tools that break the app on purpose, so the numbers on the other tabs can be trusted. These controls used to be the last chip on the Performance tab, which read as a category error: every Performance section reports something that happened, while these go out and cause it.
Pick a thread, pick a duration (100 ms to 3 s, or type your own), and block it:
- JavaScript: shows up as a long task and drops the FPS reading. Works everywhere.
- Main (UI): freezes what you see and touch while the JS numbers stay perfectly healthy. That gap is the blind spot the frame-rate card warns about, and this is how you see it for yourself. Needs a development build.
There's also a Crash button for either thread, which takes two taps. The two are not the same event, and the difference is worth seeing once: a JS throw is caught and reported before you let go of the button, while a main-thread crash ends the process and is read back off disk at the next launch. Either way the report is waiting on the Crashes tab.
There is no record button and nothing to clear, so the tab carries no toolbar.
[!WARNING] The Debug tab isn't restricted to development builds, and it doesn't go through the recording gate either: the buttons call straight into the native module, so they work whenever the panel is on screen, whether or not
.init()ran. Guarding the<DevtoolsOverlay />mount is what keeps them out of a release.
Themes
The header is one row: the tabs, a palette button, then close. The button lists every theme and switches the panel immediately. Seven ship with it:
| Id | |
| ----------------- | ---------------------------------------------------- |
| light | Chrome DevTools' light Network tab, the default |
| dark | Chrome DevTools' own dark theme |
| dracula | Dracula |
| nord | Nord |
| monokai | Monokai, as in TextMate and Sublime |
| one-dark | One Dark, from Atom |
| solarized-light | Solarized Light, the second of the two light options |
Each is the project's published colours mapped onto this panel's tokens, not an approximation.
Pick which one it opens with, and add your own:
export const devtools = createDevtoolsClient({
defaultTheme: 'midnight',
themes: {
midnight: { base: 'dark', colors: { accent: '#a78bfa' } },
},
});A theme names a base to inherit from (any of the seven) and overrides only the tokens it cares about,
so a one-colour change is a one-line entry rather than a copy of all 21 that rots whenever a token is
added. Reuse a built-in's id as your own name and you replace it. A defaultTheme naming something that
was never registered is ignored rather than leaving the panel unstyled.
The choice lives in memory for the session, like the tab you last had open. Persisting it would mean
taking a storage dependency for a devtools colour scheme. The full token list is the Palette type,
exported from the package root.
Capturing inside an in-app browser
A <WebView> runs its own separate JavaScript, invisible to everything above, so it needs two props wired
up:
import { WebView } from 'react-native-webview';
import { devtools } from './devtools';
<WebView
source={{ uri: 'https://example.com' }}
injectedJavaScriptBeforeContentLoaded={devtools.getWebViewInjectedJavaScriptBeforeContentLoaded(
'my-webview'
)}
onMessage={(event) => devtools.handleWebViewMessage(event)}
/>;Declare the name up front so a typo can't silently swallow everything:
export const devtools = createDevtoolsClient({
webviewSources: ['my-webview'],
});That covers both the page's requests and its console output. Rows show up tagged
WebView::[my-webview] in either tab, and the Source chips can filter them apart from your app's own.
TypeScript will reject a name you didn't declare.
[!IMPORTANT] Use
injectedJavaScriptBeforeContentLoaded, notinjectedJavaScript: the latter runs after the page's own scripts have already fired, so their requests escape.
Three optional extras, only needed if you want throttling to reach the page too:
ref={devtools.getWebViewRef('my-webview')} lets a speed change reach an already-open page,
userAgent={devtools.getWebViewUserAgent()} applies the browser override for real, and
onShouldStartLoadWithRequest={devtools.shouldAllowWebViewRequest} blocks navigation while Offline is on.
A page can never be fully throttled: images, stylesheets and scripts the browser loads by itself still
go out at full speed.
Configuration
createDevtoolsClient(config?). Every option is optional, and the defaults are what most apps want.
| Option | Type | Default | Description |
| ------------------------------------ | ----------------------------- | ----------- | --------------------------------------------------------------------------------------------- |
| defaultTheme | string | 'light' | Which theme the panel opens with: a built-in or one of yours. |
| themes | Record<string, ThemeConfig> | undefined | Your own themes: a base to inherit and the tokens to override. |
| webviewSources | string[] | undefined | Names of in-app browser views allowed to report in, for the Network and Console tabs. |
| network.http | boolean | true | Capture plain requests — fetch, Expo's fetch, XMLHttpRequest, a JSI client, a page's own. |
| network.websocket | boolean | true | Capture WebSocket connections and their messages, the app's own and a page's. |
| network.sse | boolean | true | Capture server-sent event streams and their events, whichever client opened them. |
| network.disabledByDefault | boolean | false | Open the Network tab not recording. The record button in its toolbar starts capture. |
| console.capture | boolean | true | Mirror console.* into the Console tab, including from declared browser views. |
| console.repl | boolean | __DEV__ | Show the > prompt. Off in release builds unless you ask for it. |
| console.context | Record<string, unknown> | undefined | Extra names an expression can use, e.g. { store, queryClient }. |
| console.disabledByDefault | boolean | false | Open the Console tab not recording. The > prompt still works while it's off. |
| performance.sampleIntervalMs | number | 1000 | How often the JS heap is read. Each read crosses into the engine, so keep it coarse. |
| performance.longTaskThresholdMs | number | 150 | Only report tasks that blocked the JS thread at least this long. |
| performance.interactionThresholdMs | number | 100 | Only report interactions that took at least this long, event to next paint. |
| performance.historySize | number | 120 | How many memory samples, long tasks, user timings and interactions are kept. |
| performance.disabledByDefault | boolean | true | Open the Performance tab not recording. On by default, since measuring costs something. |
| storage.adapters | StorageAdapterDefinition[] | undefined | The stores the Storage tab can see. Nothing is found automatically — see Storage. |
| storage.maxKeys | number | 1000 | Keys read per store before it stops and reports how many it skipped. |
| storage.readOnly | boolean | false | Make every store read-only. One adapter can still override it. |
Every field of every panel, and the rest of the API (the client's methods, the overlay's props, the theme
tokens, the exported types) is in the reference, also shipped
in this package as REFERENCE.md.
Leaving it in production
Shipping the code is safe. Until .init() runs, nothing is patched and nothing is recorded, so the cost of
leaving the package in a production bundle is the bundle size and nothing else. There are two switches, and
they do different jobs:
- Capture:
if (DEVTOOLS_ENABLED) devtools.init();patchesfetch,XMLHttpRequestandconsole. Skip it and nothing is ever recorded. - Access:
{DEVTOOLS_ENABLED && <DevtoolsOverlay />}draws the floating button. Skip it and there's no way into the panel.
DEVTOOLS_ENABLED is whatever condition you want, evaluated at runtime.
process.env.EXPO_PUBLIC_APP_ENV !== 'prod' from the Quick start and __DEV__ are the two
usual choices; anything else works too, including a value you fetch for a specific user.
[!NOTE] Guarding the overlay is belt and braces rather than load-bearing: it hides itself until
init()has brought the panel up, so skipping theinit()call alone is enough. Guarding both is still worth doing — it keeps the component out of the render tree entirely.
That also settles the Debug tab, whose buttons call straight into the native module and are not
restricted to development builds. They live behind the panel, and the panel is now unreachable without
init().
Example app
example/ is a runnable Expo app for trying all of this against real traffic. It has one screen per tab,
each a wall of buttons:
- Requests fires GET, POST and DELETE, downloads an image and uploads one, across
fetch,XMLHttpRequestand axios so you can watch all three interception paths land in the same list. Its WebView sub-tab loads a real external site, whose own requests arrive tagged with their source. - Console covers every kind of output worth testing, grouped into levels, argument shapes (mixed
arguments, nested objects, arrays of objects, class instances,
MapandSet, exotic primitives, empty values) and edge cases (circular references, a throwing getter, an unhandled rejection, a message repeated five times, a very long message, and a 600-entry flood). - Performance blocks the JS thread for 60ms, 150ms, 400ms or three bursts in a row, runs a deliberately
slow tap handler, records
markandmeasurepairs with and withoutdetail, and allocates memory you can retain or release to make the heap chart move.
example/devtools.ts doubles as a worked configuration: a dark default theme, a custom midnight one, two
declared webviewSources, a console.context you can reach from the prompt, and all four storage adapters
registered against real AsyncStorage, MMKV, SecureStore and an in-memory Map. Its Storage screen seeds
each store with a spread of value shapes to poke at.
AsyncStorage and SecureStore ship inside Expo Go, so bun run start exercises them as-is. MMKV doesn't —
the example catches that and registers the other three stores instead of crashing, and bun run ios gets
you all four.
cd example
bun run start # Expo Go / dev client
bun run ios # or: bun run android (full native build)Changelog
Every published release is at axonpack.github.io/docs/expo-devtools/changelog,
and in CHANGELOG.md.
What's built, and what isn't
See the feature list for what's built, what the platform genuinely can't do (and why this doesn't fake it), and what's still on the table.
