Building Interactive Widgets in Expo-Managed React Native Apps with iOS App Groups and Custom Modules

Learn how to create a dynamic, interactive widget in an Expo managed React Native app, that syncs seamlessly with its parent app using shared state via `UserDefaults` and custom Expo modules.


Problem Statement

Creating interactive widgets in Expo-managed React Native apps can be challenging due to the abstraction provided by the Expo framework, which limits direct access to platform-specific features. Widgets typically handle user interactions, update their state dynamically, and synchronize with the parent app. However, achieving this functionality requires managing state updates, creating native modules, and ensuring the widget adheres to Apple's guidelines.


Objective

To create interactive widgets in an Expo-managed React Native app that:

  1. Handle user interactions directly from the widget.
  2. Synchronize state updates between the widget and the parent app.
  3. Dynamically update UI components based on shared state.

Step 1: Create a Test App

Run the following command to scaffold the Expo app:

npx create-expo-app@latest ExpoWidget

Add a dummy state to the app for testing, with a basic UI to display the state and enable interaction (see the code here):

Image

Step 2: Create the Widget

iOS Setup: Create a Widget Extension in Xcode

  1. Open your project in Xcode.
  2. Go to File > New > Target.
  3. Select Widget Extension and click Next.
  4. Give your widget a name and ensure the parent app is selected as the host app.
Image

To clarify, the project is named ExpoWidget, while the widget extension is named TestWidget — a different name for the project might have avoided potential confusion.

Image

Configure App Groups

Configuring App Groups is essential to enable data sharing between the parent app and its widget extension. By adding an App Group capability, both components can access shared storage (e.g., UserDefaults), which is crucial for synchronizing state between the app and the widget.

  1. Navigate to your app's target settings in Xcode.
  2. Under the Signing & Capabilities tab, add a new capability for App Groups.
Image
  1. Create a new App Group and ensure both the app and widget extension are part of this group.
Image

Edit the Widget Code

Navigate to the newly created widget extension ./ios/TestWidget.

Open the TestWidget.swift file and implement the widget’s UI and logic. Use SwiftUI to design the widget interface.

In the default configuration, the widget focused on displaying static information, such as the current time and a configurable emoji. The updated widget introduces dynamic interaction, allowing users to increment or decrement a counter directly from the widget interface. Below is a detailed breakdown of the changes and the rationale behind them:

1. Replaced AppIntentTimelineProvider with TimelineProvider

The widget used AppIntentTimelineProvider, designed for widgets supporting App Intents that provide configurations (e.g., selecting emojis). This was tied to ConfigurationAppIntent. The TimelineProvider is now used for generating widget data based on shared state UserDefaults. This change allows the widget to refresh dynamically based on external data (like the counter value).

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), count: 0)
    }
    
    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> Void) {
        let entry = SimpleEntry(date: Date(), count: 0)
        completion(entry)
    }
  
    func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> Void) {
        var entries: [SimpleEntry] = []
        let appGroupID = "group.com.peterarontoth.ExpoWidget" // Replace with your App Group ID
        let sharedDefaults = UserDefaults(suiteName: appGroupID)
        let count = sharedDefaults?.integer(forKey: "counter") ?? 0
        let entry = SimpleEntry(date: Date(), count: count)
        entries.append(entry)

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}

2. Added Counter State Management

The updated code retrieves and manages a counter value from UserDefaults shared via an App Group:

let appGroupID = "group.com.peterarontoth.ExpoWidget" // Replace with your App Group ID
let sharedDefaults = UserDefaults(suiteName: appGroupID)
let count = sharedDefaults?.integer(forKey: "counter") ?? 0

This ensures the counter state is persistent and synchronized between the widget and the app.

3. Dynamic Timeline Generation

The timeline now contains a single entry that reflects the current counter value:

let entry = SimpleEntry(date: Date(), count: count)
entries.append(entry)

let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)

This approach ensures that the widget always displays the latest counter value.

4. Redesigned SimpleEntry to Include Counter

Original Code:

The SimpleEntry struct stored a date and the configuration:

struct SimpleEntry: TimelineEntry {
  let date: Date
  let configuration: ConfigurationAppIntent
}

Updated Code:

The SimpleEntry now includes the count property:

struct SimpleEntry: TimelineEntry {
  let date: Date
  let count: Int
}

5. Introduced Increment and Decrement Buttons

Original Code:

The widget displayed static text without interactive elements.

Updated Code:

Interactive buttons were added for incrementing and decrementing the counter:

struct IncrementButton: View {
  var body: some View {
    Button("+", intent: IncrementCounter()) ...
  }
}

struct DecrementButton: View {
  var body: some View {
    Button("-", intent: DecrementCounter()) ...
  }
}

These buttons are styled using SwiftUI and linked to custom AppIntent definitions (IncrementCounter and DecrementCounter).

6. Defined Increment and Decrement Intents

struct IncrementCounter: AppIntent {
  func perform() async throws -> some IntentResult {
    let count = sharedDefaults?.integer(forKey: "counter") ?? 0
    sharedDefaults?.set(count + 1, forKey: "counter")
    WidgetCenter.shared.reloadAllTimelines()
    return .result()
  }
}

struct DecrementCounter: AppIntent {
  func perform() async throws -> some IntentResult {
    let count = sharedDefaults?.integer(forKey: "counter") ?? 0
    sharedDefaults?.set(count - 1, forKey: "counter")
    WidgetCenter.shared.reloadAllTimelines()
    return .result()
  }
}

These intents update the counter in UserDefaults and refresh the widget timeline via WidgetCenter.shared.reloadAllTimelines().

7. Updated Widget UI

VStack {
  Text("Count:")
  Text("(entry.count)")
  HStack(spacing: 16) {
    DecrementButton()
    IncrementButton()
  }
}

The UI now displays the counter value and action buttons.

8. Changed Widget Configuration to StaticConfiguration

The widget used AppIntentConfiguration:

AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider())

Updated Code:

The widget now uses StaticConfiguration:

StaticConfiguration(kind: kind, provider: Provider()) { entry in
  TestWidgetEntryView(entry: entry)
}

This simplifies the widget configuration since the counter state is managed externally via UserDefaults, and no user-provided configuration is required.

The final code for the widget with some additional improvements and styling can be found in the TestWidget.swift file.

If you build the app now,

yarn expo run:ios --device

you will be able to add the widget to your homescreen and see the current count and buttons to increment or decrement it.

Image

Step 3: Create Custom Expo Modules

Expo Managed Workflow provides a preconfigured environment that abstracts away many native platform-specific APIs. This abstraction ensures simplicity but limits direct interaction with features like UserDefaults or custom native modules.

By creating a custom Expo module, you can bridge this gap, as it allows allow you to write native code that interacts directly with platform-specific features like UserDefaults

Create a Custom Module for Shared UserDefaults

Run the command:

npx create-expo-module@latest --local

and give the module a name like shared-user-defaults.

Image

The SharedUserDefaultsModule is a custom Expo module that lets your app work with shared UserDefaults storage, designed for App Groups on iOS. It includes two easy-to-use functions: one to save an integer value with a specific key (setDataInSharedUserDefaults) and another to fetch an integer using its key (getDataFromSharedUserDefaults). By using the provided App Group ID, this module makes it simple to share data between your app and its extensions, like widgets or companion apps.

Inside SharedUserDefaultsModule.swift:

import ExpoModulesCore

public class SharedUserDefaultsModule: Module {
  public func definition() -> ModuleDefinition {
    Name("SharedUserDefaults")

    AsyncFunction("setDataInSharedUserDefaults") { (value: Int, key: String) in
      let appGroupID = "group.com.example.ExpoWidget" // Replace with your App Group ID
      if let sharedDefaults = UserDefaults(suiteName: appGroupID) {
        sharedDefaults.set(value, forKey: key)
      }
    }

    AsyncFunction("getDataFromSharedUserDefaults") { (key: String) -> Int? in
      let appGroupID = "group.com.example.ExpoWidget" // Replace with your App Group ID
      return UserDefaults(suiteName: appGroupID)?.integer(forKey: key)
    }
  }
}

Create a Widget Refresh Module

Run the command:

npx create-expo-module@latest --local

and give it a name like widget-refresh.

Image

The WidgetRefreshModule is key to keeping your widgets up-to-date with the latest app data. It includes a simple function, reloadAllTimelines, that refreshes all widget timelines so they always display the most current information. This is especially useful for apps with dynamic content that needs to stay in sync across the app and its widgets.

Inside WidgetRefreshModule.swift:

import ExpoModulesCore
import WidgetKit

public class WidgetRefreshModule: Module {
  public func definition() -> ModuleDefinition {
    Name("WidgetRefresh")

    AsyncFunction("reloadAllTimelines") {
      if #available(iOS 14.0, *) {
        WidgetCenter.shared.reloadAllTimelines()
      }
    }
  }
}

Before trying to build the app, you need to run:

cd ios
pod install

This step ensures that the native modules you created, along with any other required libraries, are properly linked and integrated into the iOS project.

Do not forget to check if your App Group IDs are correctly set in both the custom modules and the widget extension.


Step 4: Synchronize State Between Widget and App

Updating the Widget from the App

Call the custom modules to update state and refresh the widget timeline:

const handleOnIncrement = useCallback(async () => {
  let newCount = counter + 1;
  setCounter(newCount);
  await SharedUserDefaultsModule.setDataInSharedUserDefaults(newCount, "counter");
  await WidgetRefreshModule.reloadAllTimelines();
}, [counter]);

Synchronizing State in the App

Now you can interact with the shared state from both the widget and the parent app. The widget can increase or decrease the counter, and the parent app can update the counter while also refreshing the widget timeline. However, any changes made on the widget side won’t automatically sync with the parent app. To fix this, you can use a simple useEffect hook in the root component of your app to listen for changes in the shared UserDefaults and keep the counter in sync.

useEffect(() => {
  (async () => {
    const count = await SharedUserDefaultsModule.getDataFromSharedUserDefaults("counter");
    setCounter(count || 0);
  })();
}, []);

Step 5: Handle App State Changes

Now, if you update the counter on the widget and then open the parent app, the counter will reflect the new value. This keeps the state perfectly synchronized between the widget and the app. One important detail to remember is to handle AppState changes, so the app refreshes its state whenever it’s opened or brought back to the foreground.

You can set up a custom hook to listen for changes in the app state:

useAppState.ts:

import { useEffect, useState } from "react";
import { AppState } from "react-native";

export function useAppState() {
  const [appState, setAppState] = useState(AppState.currentState);

  useEffect(() => {
    const subscription = AppState.addEventListener("change", setAppState);
    return () => subscription.remove();
  }, []);

  return appState;
}

Modify the root component to listen for changes in the app state and refresh the counter accordingly:

export default function Index() {
  const [counter, setCounter] = useState(0)
  const appState = useAppState()

  useEffect(() => {
    if (appState === "active") {
      (async () => {
        try {
          const count = await SharedUserDefaultsModule.getDataFromSharedUserDefaults("counter")
          setCounter(count || 0);
        } catch (error) {
          return error
        }
      })()
    }
  }, [appState])

Summary

The diagram illustrates the flow of state management and communication between a parent app, custom Expo modules, and a widget in an Expo-managed React Native project. The Parent App handles state updates via an AppState Listener and useEffect hook, fetching state from UserDefaults and triggering actions for incrementing or decrementing the state. These actions interact with the Custom Expo Modules, which include the WidgetRefreshModule (to refresh widgets) and SharedUserDefaultsModule (to share state). The Widget reads state from UserDefaults, displays it on the UI, and allows users to update the state through increment/decrement buttons.

Image

That’s it!

With a mix of Expo's custom modules and iOS's App Groups and WidgetKit, you can build interactive widgets that sync perfectly with your app.


Final Considerations

Performance Concerns

Excessive calls to WidgetCenter.shared.reloadTimelines may affect system performance and battery life. Use this method judiciously.

App Store Guidelines

Apple discourages widgets from being overly interactive or duplicating app functionality. Focus on providing updates rather than facilitating extensive user input to avoid rejection during app review.