◉RORK LABJP
●EXPO — EAS Observe now records native crashes next to JavaScript errors (Oct 7)●SDK 58 — SDK 58 Beta has been out since Sep 15. The stable date is still unconfirmed●RN 0.88 — React Native 0.88.x is scheduled for Oct 12, 4 days left●Q&A — People are asking why expo-widgets render blank only in production builds●RORK — GPT-6.1 Sol was added on Sep 29, available on Pro and Max plans●NEW — Japanese headings not bold on Android: how to isolate it●EXPO — EAS Observe now records native crashes next to JavaScript errors (Oct 7)●SDK 58 — SDK 58 Beta has been out since Sep 15. The stable date is still unconfirmed●RN 0.88 — React Native 0.88.x is scheduled for Oct 12, 4 days left●Q&A — People are asking why expo-widgets render blank only in production builds●RORK — GPT-6.1 Sol was added on Sep 29, available on Pro and Max plans●NEW — Japanese headings not bold on Android: how to isolate it
Articles/Dev Tools
⬡ Dev Tools/2026-08-24Intermediate

I Moved My Dark Mode Checks Into DevTools, and Only Four Places Still Need a Real Device

Expo SDK 57 added light and dark emulation to React Native DevTools, which ended my trips to the Settings app. What it swaps is the value JavaScript reports, so a few places still need a real toggle. Here is how to find that boundary in your own project.

Expo213SDK 57Dark ModeReact Native238DevTools

I used to lose half an hour every morning walking back and forth to the Settings app.

Darken the list screen, check it, switch back to light. Darken the detail screen, check it, switch back again. In a wallpaper app where most of the screen is filled by an image, very few surfaces actually change color — but you still have to look at every screen to be sure. Ten seconds per toggle across twenty screens is close to seven minutes of pure walking around.

After upgrading to Expo SDK 57, I noticed that React Native DevTools now has a switch that emulates light and dark mode. One toggle, no device settings.

My first thought was that this solved the whole problem. It did not. But drawing a clear line around what it does not cover made my pre-release routine much shorter, and that line is what I want to write down here.

What the toggle swaps is the value JavaScript reports

Understanding the mechanism makes every later decision faster.

React Native ships an Appearance module, and Appearance.setColorScheme() overrides the color scheme that the JavaScript side reports. The DevTools emulation makes sense as a way to drive that override from the tooling instead of from your own code.

So anything that decides its colors by reading useColorScheme() follows the toggle immediately.

import { useColorScheme, View, Text } from 'react-native';
 
export function Card({ title }: { title: string }) {
  const scheme = useColorScheme(); // 'light' | 'dark' | null
  const isDark = scheme === 'dark';
 
  return (
    <View style={{ backgroundColor: isDark ? '#16181d' : '#ffffff' }}>
      <Text style={{ color: isDark ? '#e8eaed' : '#1f2328' }}>{title}</Text>
    </View>
  );
}

NativeWind's dark: modifier behaves the same way, since it reads from the same source underneath.

The flip side is the part worth remembering: anything that reads the OS setting natively does not follow along. JavaScript can report "we are dark now," and UIKit or the Android resource resolver will happily keep using whatever the device is actually set to.

That is the boundary. The reason it confused me at first is that both kinds of surface tend to sit on the same screen.

Put the sources side by side and confirm the boundary yourself

Rather than trusting a description of the mechanism, it is faster to confirm it inside your own project and your own version. I built a small diagnostic screen that displays three color scheme sources at once.

import { useEffect, useState } from 'react';
import { Appearance, useColorScheme, View, Text, Platform } from 'react-native';
import { WebView } from 'react-native-webview';
 
const PROBE_HTML = `
<html><body style="margin:0;font:16px -apple-system,system-ui">
  <div id="out" style="padding:12px"></div>
  <script>
    const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    document.getElementById('out').textContent = 'WebView: ' + (dark ? 'dark' : 'light');
    document.body.style.background = dark ? '#16181d' : '#ffffff';
    document.body.style.color = dark ? '#e8eaed' : '#1f2328';
  </script>
</body></html>`;
 
export function ColorSchemeProbe() {
  const hookScheme = useColorScheme();
  const [snapshot, setSnapshot] = useState(Appearance.getColorScheme());
 
  useEffect(() => {
    const sub = Appearance.addChangeListener(({ colorScheme }) => {
      setSnapshot(colorScheme);
    });
    return () => sub.remove();
  }, []);
 
  return (
    <View style={{ gap: 8, padding: 16 }}>
      <Text>useColorScheme(): {String(hookScheme)}</Text>
      <Text>Appearance.getColorScheme(): {String(snapshot)}</Text>
      <Text>Platform: {Platform.OS}</Text>
      <WebView source={{ html: PROBE_HTML }} style={{ height: 64 }} />
    </View>
  );
}

Keep that screen open and flip the DevTools toggle. If the first two lines change and the WebView line stays put, the boundary is where I expect it to be. If the WebView follows along too, then my assumption is out of date for your version, and you should re-check the four places below on your own terms.

The check takes about a minute, and it is worth re-running on every SDK bump. A single screen in your own project beats secondhand claims about version differences.

The four places the emulation does not reach

With the boundary confirmed, here is what still requires flipping the device setting.

SurfaceHow its colors are decidedHow to check it
Splash screen Drawn by the OS from native resources before your app runs Flip the device setting and cold start
Native alerts and keyboard Follow the UIKit / Android trait Flip the device setting and open that screen
prefers-color-scheme inside a WebView The WebView reads the OS setting itself Flip the device setting, or inject the scheme explicitly
Android system bar backgrounds styles.xml plus your edge-to-edge configuration Flip the device setting and look at a real device

Of those, the WebView is the one I would simply take control of. Leaving it to the OS produces the mismatch where your app is dark and the embedded page is bright white — very visible in apps that show terms of service or help content in a WebView.

const scheme = useColorScheme() ?? 'light';
 
<WebView
  source={{ uri: 'https://example.com/help' }}
  injectedJavaScriptBeforeContentLoaded={`
    document.documentElement.dataset.scheme = ${JSON.stringify(scheme)};
    true;
  `}
/>

If the receiving CSS keys off [data-scheme="dark"] instead of prefers-color-scheme, the whole app resolves colors from a single source. It also starts following the DevTools toggle, which removes one of the four blind spots outright.

The native keyboard is partially fixable. If you pass keyboardAppearance on TextInput from your color source, at least the iOS keyboard follows the JavaScript decision.

const scheme = useColorScheme() ?? 'light';
 
<TextInput
  placeholder="Search"
  keyboardAppearance={scheme === 'dark' ? 'dark' : 'light'}
/>

In an app with a search field, a mismatch here is noticeable every single time someone types. Native dialogs such as Alert.alert() offer no equivalent knob, so those genuinely have to be looked at on a device. Splitting the list into "things I can fix" and "things I can only look at" makes it much easier to decide where the checking time goes.

For the splash screen, not having a light and dark variant at all is a perfectly reasonable choice. A single brand color removes it from the list of things to verify. That is what I do in my wallpaper apps, and I no longer spend time chasing flashes during launch.

Android system bars deserve one look on a real device right after upgrading, since SDK 57 includes edge-to-edge fixes. If you have hand-edited native configuration, it helps to work through Auditing the native changes prebuild will erase before you upgrade to Expo SDK 57 first, so you are not debugging two things at once.

The pre-release routine I rewrote

Once the boundary was clear, the routine collapsed into three steps.

Sweep every screen in DevTools, toggling once

Leave the app running, flip the toggle to dark, and walk the whole app. At this stage you are only looking at surfaces that read the JavaScript color source: text contrast, how shadows read, dividers dissolving into the background. In my case roughly eight out of ten dark mode defects showed up right here.

Flip the device setting and check four places

Then set the device itself to dark and check exactly four things: a cold start, one native alert, one screen containing a WebView, and on Android one screen with visible system bars. Compared with walking every screen twice, this part now takes a couple of minutes.

Watch one re-render immediately after switching

Both the emulation and the real toggle trigger a re-render. If a white frame appears or state gets dropped at that moment, that is a separate problem. If your setup restarts the process on theme change, Fixing the white screen on theme switch without recreate() covers closely related ground.

Shorter checks get run more often

What I have described here is less about a new feature and more about friction going down.

A seven-minute check invites the thought "nothing changed since last time, let me skip it." A one-minute check gets run every time. Since the frequency went up, the number of dark mode defects reaching production has clearly dropped.

As a next step, drop the diagnostic screen into one project. Five minutes will tell you where the boundary sits in your version, and from there you can shorten your own routine by hand.

Accessibility settings share the same "you only really know on a device" quality. I collected what I do for VoiceOver and Dynamic Type in Bringing VoiceOver and Dynamic Type up to production quality in Rork, and handling both at once keeps all the settings-driven checks in one pass.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $15 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ Dev Tools2026-09-10
I Added a Dim Screen Button, and iPhones Stayed Dark After the App Was Closed
In expo-brightness, setBrightnessAsync applies only to the current activity on Android, but changes the device brightness itself on iOS. Here is why the cleanup burden falls on one platform only, and a hook that restores brightness through AppState.
⬡ Dev Tools2026-09-04
EAS secret visibility does not keep a value out of your app — deciding prefix and visibility separately
The EXPO_PUBLIC_ prefix decides what ships inside your app; EAS visibility decides who can read it. Why stacking them blanks a value on OTA updates, and how to check your build.
⬡ Dev Tools2026-09-01
Adding image paste with expo-paste-input, and moving the disappearing file:// URIs out of cache
How to let users paste images and GIFs into a chat input with expo-paste-input. The URIs that onPaste hands you are temporary files that can vanish before the user hits send. Here is the relocation code and the order I verify it on real devices.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links