Syncing data with SolidJS and iOS widgets
With SolidJS + NativeScript, we can keep the UI and state orchestration in TypeScript while still using native iOS capabilities (WidgetKit, AppIntents, Swift Charts, Darwin notifications).
This implementation focuses on a TypeScript-first architecture where the SolidJS app and iOS widget stay in sync.
It was scaffolded using NativeScript's widget generator:
ns widget ios
The Solid app base was created with the following, then choosing TypeScript at the prompts:
ns create myapp --solid
Docs: https://docs.nativescript.org/guide/widgets-ios
With SolidJS + NativeScript, we can keep the app UI and state orchestration in TypeScript while still using native iOS capabilities (WidgetKit, AppIntents, Swift Charts, Darwin notifications).
This gives us:
- Reactive chart updates in app (
createSignal) - Shared App Group persistence for app/widget sync
- Fast cross-process iOS eventing when host app is active
- A heartbeat-based fallback when host app is not active
Current architecture (with heartbeat fallback)
TSX integration
The app entry point for this feature lives in src/components/home.tsx in the sample project, referenced at the bottom of this post.
What home.tsx does
- Hydrates initial chart values from shared widget data
- Maintains chart UI state as a Solid signal (
chartData) - Applies randomization and persists shared payloads
- Observes widget-triggered notifications (
widgetRandomizeData) - Wires app lifecycle heartbeat (
resumeEvent/exitEvent) - On resume, consumes pending widget action if app was launched by widget fallback path
Code walkthrough
Below are the three integration moments that matter most.
1) Initialize from shared storage and keep chart state reactive
const sharedData = readSharedWidgetData();
const initialValues = sharedData?.values?.length ? sharedData.values : defaultValues;
const [chartData, setChartData] = createSignal<LineChartData>(buildChartData(initialValues));
const applyValues = (values: ChartPoint[]) => {
setChartData(buildChartData(values));
setTimeout(() => {
chartRef?.animate(updateAnimation);
}, 0);
};
This is the app-side "source of render truth." Storage hydration happens once, then Solid signal updates drive chart UI.
2) React to widget-originated Darwin events
observeWidgetNotifications();
observer = Application.ios.addNotificationObserver(WIDGET_RANDOMIZE_DATA, () => {
const payload = readSharedWidgetData();
if (payload?.values?.length) {
randomizeData(payload.values, false);
return;
}
randomizeData();
});
When the host app is active, widget taps arrive via notification and the chart updates immediately without app relaunch.
3) Wire lifecycle heartbeat + pending action replay
disposeLifecycle = wireHostLifecycleHeartbeat(() => {
const pendingAction = consumePendingWidgetAction();
if (pendingAction === WIDGET_PENDING_ACTION_RANDOMIZE) {
const payload = readSharedWidgetData();
if (payload?.values?.length) {
randomizeData(payload.values, false);
return;
}
randomizeData();
return;
}
syncChartFromSharedData();
});
This is the fallback path. If the widget had to launch the app (because heartbeat was stale), the app resumes, consumes the pending action, and reconciles state.
TypeScript architecture (src/widget/*)
The implementation is split into clear boundaries:
types.ts
Defines shared app-side contracts:
ChartPointSharedWidgetChartData
constants.ts
Single source for keys and chart settings:
- Storage/event keys (
widgetChartData,widgetRandomizeData,widgetHostHeartbeat,widgetPendingAction) - Pending action values (
randomizeData) - Heartbeat window (
45_000ms) - Chart config + animation defaults
manager.ts
Native facade over AppleWidgetUtils:
- Observe widget notifications
- Read/write/remove shared strings
- Trigger widget timeline reload
- Consume pending widget action atomically (read + clear)
This keeps native interop in one place.
data.ts
Data modeling helpers:
- Build
LineChartDatafor the in-app chart - Build shared payload with
delta/percent - Read/validate shared payload from App Group
- Persist payload and reload widget
- Generate randomized values
lifecycle.ts
Host activity signaling:
- Write heartbeat timestamp on resume
- Clear heartbeat on exit
- Evaluate heartbeat recency
- Provide
wireHostLifecycleHeartbeat(onResume)setup/teardown helper
This is the key piece that allows the widget to choose the best interaction path.
Widget-side behavior (Swift)
The widget (WidgetHomeScreenWidget.swift) now has two intent paths for the same user action:
-
RandomizeDataIntent(host heartbeat is recent)- Randomize + save shared data
- Post Darwin notification (
widgetRandomizeData) - Reload widget timelines
-
RandomizeDataAndOpenAppIntent(host heartbeat is stale)- Randomize + save shared data
- Mark
widgetPendingAction = randomizeData openAppWhenRun = trueto launch host app- App resumes and handles pending action in
home.tsx
This avoids losing interactions when the host app is not running.
Shared payload contract
Both sides share the same shape for chart + summary metadata:
interface SharedWidgetChartData {
title: string;
values: { x: number; y: number }[];
updatedAt: number;
delta: number;
percent: number;
}
Keeping this contract stable is what makes app/widget synchronization reliable.
Practical outcomes
home.tsxstays focused on orchestration and UI state- Native bridge logic is centralized in TypeScript modules
- Widget interaction is resilient whether app is active or not
- Shared storage remains the single source of truth
Sample project
You can explore the full implementation in this sample repo.