Home screen widgets on Android are native surfaces the launcher draws—they are not React Native views. To still describe layout in JavaScript and ship alongside an Expo app, you can use react-native-android-widget: it maps a small tree of widget primitives (FlexWidget, TextWidget, …) to RemoteViews, and gives you hooks to refresh when your app’s data changes.
This post walks through that end-to-end using a concrete example: a weekly habit tracker widget. Each habit is a row; each day of the current week is a cell, so users can see completions and streaks at a glance. You will register the widget in app.json, implement the UI as a buildHabitsWeekWidgetTree function, handle system-driven updates (widget added, periodic refresh) with registerWidgetTaskHandler, and push foreground updates with requestWidgetUpdate after the user edits habits in the app—so the home screen stays aligned with your store and API.
android/ project (widgets do not run in Expo Go).react-native-android-widgetAdd the package first so the JS API (FlexWidget, registerWidgetTaskHandler, requestWidgetUpdate) and the Expo config plugin (react-native-android-widget/app.plugin) resolve correctly:
1npx expo install react-native-android-widget
expo install picks a version compatible with your Expo SDK (for reference, ^0.20.1 is a version that works well in production). After installing or upgrading any native module, create a new development build—a plain Metro reload is not enough for the Android widget wiring.
Add the library’s config plugin under expo.plugins in app.json (or app.config.js). Each entry under widgets needs a stable name—you will reference it from JavaScript.
1// app.json — fragment inside expo.plugins2;[3 "react-native-android-widget/app.plugin",4 {5 widgets: [6 {7 name: "HabitsWeek",8 label: "Habit tracker · This week",9 description: "Current week habit check-ins at a glance.",10 minWidth: "250dp",11 minHeight: "110dp",12 targetCellWidth: 4,13 targetCellHeight: 3,14 previewImage: "./assets/images/icon.png",15 resizeMode: "horizontal|vertical",16 updatePeriodMillis: 1800000,17 },18 ],19 fonts: [20 "./assets/fonts/Nunito-Bold.ttf",21 "./assets/fonts/Nunito-Medium.ttf",22 "./assets/fonts/Nunito-Regular.ttf",23 ],24 },25]
name: must match the string you use in JS (see step 2).fonts: optional; TextWidget can reference fontFamily values that match these files.expo prebuild / EAS) so AndroidManifest.xml, widget XML, and providers are generated.Export a constant and reuse it everywhere (plugin, task handler, refresh helper):
1// widgets/habitsWeekConstants.js2/** Must match the widget `name` in app.json (react-native-android-widget plugin). */3export const HABITS_WEEK_WIDGET_NAME = "HabitsWeek"
Typos here show up as “widget never updates” bugs, not compile errors.
Widgets are not View / Text. Use FlexWidget, TextWidget, and related primitives from react-native-android-widget. Style props mirror a subset of flexbox and typography your layout actually needs.
The habit widget composes a header row, optional loading/error copy, and a 7-day matrix of habits using only these primitives—no hooks inside the tree; pass everything as arguments to a pure buildHabitsWeekWidgetTree function:
1// widgets/HabitsWeekWidget.jsx (excerpt)2import { FlexWidget, TextWidget } from "react-native-android-widget"34export function buildHabitsWeekWidgetTree(5 _widgetInfo,6 { habits = [], loading, error },7) {8 return (9 <FlexWidget10 style={{11 width: "match_parent",12 height: "match_parent",13 backgroundColor: "#0a0a0a",14 padding: 12,15 flexDirection: "column",16 flexGap: 8,17 }}18 clickAction="OPEN_APP"19 >20 {/* loading / error / list branches */}21 </FlexWidget>22 )23}
clickAction="OPEN_APP" (or other supported actions) wires taps to intents the library documents.registerWidgetTaskHandler runs when Android asks the widget to refresh (add, periodic update, etc.). Import this file once on Android—a common pattern is a side-effect require from the root layout (see step 6).
Critical detail from production: render something immediately on WIDGET_ADDED. If you only await network first, the system can keep an empty RemoteViews and the widget looks transparent or broken.
First-party Android widgets (Clock, Weather, Google Photos frames) and popular third-party launchers all assume the first paint can happen before your network round-trip finishes. Shipping a loading or placeholder tree on WIDGET_ADDED matches what users expect from the home screen—same idea as skeleton UI in your React Native screens, but the system will not wait for your async work.
1// widgets/registerAndroidWidgets.js (excerpt)2import { registerWidgetTaskHandler } from "react-native-android-widget"34registerWidgetTaskHandler(5 async ({ widgetInfo, widgetAction, renderWidget }) => {6 if (widgetInfo.widgetName !== HABITS_WEEK_WIDGET_NAME) return7 if (widgetAction === "WIDGET_DELETED") return89 if (widgetAction === "WIDGET_ADDED") {10 renderWidget(11 buildHabitsWeekWidgetTree(widgetInfo, {12 habits: [],13 loading: true,14 error: null,15 }),16 )17 }1819 try {20 await api.loadToken()21 if (!api.token) {22 renderWidget(23 buildHabitsWeekWidgetTree(widgetInfo, {24 habits: [],25 error: "Open the app and sign in.",26 loading: false,27 }),28 )29 return30 }31 const habits = await api.getHabits()32 renderWidget(33 buildHabitsWeekWidgetTree(widgetInfo, {34 habits,35 loading: false,36 error: null,37 }),38 )39 } catch (e) {40 renderWidget(41 buildHabitsWeekWidgetTree(widgetInfo, {42 habits: [],43 error: e?.message || "Could not load.",44 loading: false,45 }),46 )47 }48 },49)
The handler can talk to AsyncStorage, your API client, or anything else already initialized in the headless JS context—keep work bounded and fail with a readable TextWidget.
requestWidgetUpdateWhen the user checks off a habit inside the app, the widget does not magically see your Zustand store. Call requestWidgetUpdate with the same widgetName and a renderWidget that reads current in-memory data (or a slim snapshot you persisted for the widget).
1// widgets/androidWidgetSync.js2import { Platform } from "react-native"3import { requestWidgetUpdate } from "react-native-android-widget"45export async function refreshHabitsWeekWidgetIfAndroid(habits) {6 if (Platform.OS !== "android") return7 try {8 await requestWidgetUpdate({9 widgetName: HABITS_WEEK_WIDGET_NAME,10 renderWidget: (widgetInfo) =>11 buildHabitsWeekWidgetTree(widgetInfo, {12 habits: habits || [],13 loading: false,14 error: null,15 }),16 })17 } catch {18 // No widget on screen or native module missing — safe to ignore19 }20}
Invoke this after habit mutations in the store, on auth changes (e.g. clear habits on logout), and from the root layout when the app becomes active so the home screen catches up after background work.
On Android only:
require the registration module so registerWidgetTaskHandler runs.Pattern:
1// app/_layout.jsx (excerpt)2import { Platform, AppState } from "react-native"3import { refreshHabitsWeekWidgetIfAndroid } from "../widgets/androidWidgetSync"4import useHabitsStore from "../stores/habitsStore"56if (Platform.OS === "android") {7 require("../widgets/registerAndroidWidgets")8}910// Inside RootLayout:11useEffect(() => {12 if (Platform.OS !== "android") return13 refreshHabitsWeekWidgetIfAndroid(useHabitsStore.getState().habits).catch(14 () => {},15 )1617 const sub = AppState.addEventListener("change", (state) => {18 if (state === "active") {19 refreshHabitsWeekWidgetIfAndroid(useHabitsStore.getState().habits).catch(20 () => {},21 )22 }23 })24 return () => sub.remove()25}, [])
app.json widget name matches HABITS_WEEK_WIDGET_NAME.buildHabitsWeekWidgetTree.WIDGET_ADDED, show a loading state before async work finishes.requestWidgetUpdate runs after mutations and when the app resumes.Creating a React Native widget on Android means three coordinated pieces: Expo plugin config (native shell), a registered task handler for system-driven refreshes, and requestWidgetUpdate from the app when your data changes. Keep the widget tree small, pure, and defensive about auth and errors—the same pattern as a weekly habit tracker on the home screen.