Datastar in Clojure Part 7 of 8
Part 7 · Replacing the React Native app (optional)

Replacing the React Native app

Optional: for building a mobile app. Skip it if you only ship to browsers; part 8 mentions it in passing but doesn't need it. Still here? Then here's the dirty little secret about Brew Board's customer app: it doesn't need any UI code of its own. None. You wrap a WKWebView (iOS) or a WebView (Android) in the thinnest native shell you can get away with, point it at the same Clojure routes the browser already uses, and let the server tell it how to navigate. A tiny bridge ferries native features back and forth as events and signals. New screen? That's a server deploy. Here's how it works, and, because nothing in this life is free, what it'll cost you.

Let me paint you a picture of the app we're replacing. I bet you've seen it, because it's the one everybody builds. Brew Board's customers order on their phones through a React Native app. It has its own screens (menu, drink customizer, cart, order status). It has its own client state (a store holding the cart and the orders). And it has its own API: a pile of JSON endpoints that exist for no reason other than to feed that app. Meanwhile, the barista board is a plain web page. So the same orders get rendered twice, by two codebases, in two languages, which is the software equivalent of hiring two crews to paint the same fence from opposite sides. And every single change to what a customer sees has to run the gauntlet: a build, App Review, and then a rollout that some of your users will cheerfully ignore for weeks. Which means your server gets to keep the old JSON shapes on life support for them. Indefinitely.

Now. Parts 1–6 built the web customer page: server-rendered views, signals for input, commands that change an atom, and a live stream that keeps every open view current. Everything that page does, the phone app does too. Everything. So the interesting question isn't whether you can put that page in a store app. Of course you can. The interesting question is how little native code it takes to do it so that the result still feels like an app, and not like a bookmark wearing a trench coat.

The shell (or: how to write as little Swift as possible)

The shell is a native app whose screens are web views. Its job description is short, and I want to dwell on that for a second, because the whole trick only works if it stays short. It does the things only native code can do, and nothing else:

And here's what the shell does not contain, which is honestly the more important list: any Brew Board screen. Any drink. Any price. Any knowledge whatsoever of orders. Those are all server routes, and you already wrote them.

On the server, the first change is almost embarrassingly small. One predicate and a layout switch:

(defn shell-request? [req]
  (boolean (some-> (get-in req [:headers "user-agent"])
                   (string/includes? "BrewShell/1"))))

(defn page [{:keys [title shell? signals stream-url]} view]
  (str "<!DOCTYPE html>"
       (h/html
        [:html {:lang "en"}
         [:head [:meta {:charset "utf-8"}]
                [:meta {:name "viewport" :content "width=device-width, initial-scale=1"}]
                [:title title]
                [:script (raw-string bridge-shim)]
                [:script {:type "module" :src datastar-src}]]
         [:body (when signals {:data-signals (json/generate-string signals)
                               :data-signals:tabid__ifmissing "crypto.randomUUID()"})
          (when-not shell?                                   ;; the shell draws its own nav bar
            [:nav.web-nav [:a {:href "/"} "Order"] " · " [:a {:href "/board"} "Board"]])
          [:div {:data-on:brew:native__window "…see below…"}]
          (when stream-url [:div {:data-init (str "@get('" stream-url "', {retry: 'always'})")}])
          view]])))

From lesson07.native-shell. Handlers call it with :shell? (shell-request? req). Notice {retry: 'always'} on the stream: that's part 6's advice for streams the server might end cleanly, say on a deploy. The tab id is part 6's too. A web view is a tab like any other, with its own draft. (And since a shell loading https:// is a secure context, crypto.randomUUID() is right there for you.)

In the shell, the web nav and the page's <h1> title go away, because the shell draws its own nav bar and title, and… that's it. That's the whole diff. Apart from that title, the #app view, the live stream, the drop-down and the commands are byte-for-byte the same. This is the part that should make you grin. (A real app would also add viewport-fit=cover and CSS env(safe-area-inset-*) padding to keep content out from under the notch and the home indicator. The toy doesn't bother. It's a toy.)

Navigation is served, not compiled in

OK, so what happens when somebody taps a link inside one of these web views? The shell intercepts the navigation. On iOS the WKNavigationDelegate gets to decide each navigation's policy; on Android it's WebViewClient.shouldOverrideUrlLoading. The shell cancels the in-page load and makes a call about how to present the URL: push a new screen onto the stack, show it as a modal, or reset to the root. And here's the part I love. The rules for that decision don't live in the app. They come from the server:

GET /native/path-config

{"rules": [
  {"patterns": ["^/$"],            "presentation": "root"},
  {"patterns": ["^/orders/\\d+$"], "presentation": "push"}
]}

The shell fetches this on launch and matches each tapped URL's path against the patterns, in order. First match wins, and a path that matches nothing gets pushed. /orders/1 is a real route, GET /orders/:id, a little page showing one of Ada's orders. When she taps it in her order list, the shell pushes it as a new screen with a native back button, exactly the way a "real" app would. And because the rules come from the server, changing them is a server deploy. Not a release. A deploy. Want order pages as a sheet instead? Change "push" to "modal" (both reference shells in native/ handle it). Want an order history screen? Render /orders/history, add a pattern, link to it. Go get lunch. Nobody had to talk to Apple.

Credit where it's due: this idea isn't ours. It comes from Hotwire Native, 37signals' iOS and Android libraries for wrapping Turbo apps. Its path configuration is JSON with rules, each a list of patterns plus a properties map. In their demo, "context": "modal" presents forms as modals, "view_controller": "numbers" routes a path to a fully native screen, and "presentation": "clear_all" on ^/$ resets the stacks. It loads the configuration from a bundled file and from a remote URL. One real difference: Hotwire Native on iOS moves a single WKWebView between view controllers, instead of one web view per screen the way our toy does. That saves memory and keeps one JavaScript context alive, which is exactly the kind of thing you do after you've actually shipped one of these.

The bridge

A web view runs your page's JavaScript, and both platforms hand you a channel between that JavaScript and native code. People tend to picture "the bridge" as some enormous slab of infrastructure with its own team and its own on-call rotation. Brew Board's is a handful of lines on each side. A handful. You could fit it on an index card, if you still own index cards.

Web → native. The page defines exactly one function. It forwards a message to whichever native channel happens to exist, and in a plain browser it shrugs and does nothing:

<script>
  window.BrewShell = {
    post(msg) {
      if (window.webkit?.messageHandlers?.brew) window.webkit.messageHandlers.brew.postMessage(msg);  // iOS
      else if (window.BrewAndroid) window.BrewAndroid.postMessage(JSON.stringify(msg));               // Android
    }
  };
</script>

On iOS, brew is a WKScriptMessageHandler the shell registered on the web view's WKUserContentController. On Android, BrewAndroid is an object added with addJavascriptInterface, whose postMessage is annotated @JavascriptInterface. And how does the server pull the trigger? With an old friend, part 2's execute-script, which appends a self-removing <script> to the body:

(defn bridge-script [msg]
  (sse/execute-script (str "window.BrewShell.post("
                           (string/replace (json/generate-string msg) "<" "\\u003c")
                           ")")))

(bridge-script {:type "haptic" :style "success"})
;; event: datastar-patch-elements
;; data: selector body
;; data: mode append
;; data: elements <script data-effect="el.remove()">window.BrewShell.post({"type":"haptic","style":"success"})</script>

Now, that string/replace is not decoration, and if you take one paranoid habit away from this post, make it this one. The message can contain text a user typed, like a customer's name. And the HTML parser ends a <script> at the first </script, period, whatever JSON thinks about it. The HTML parser does not care about your JSON. It has never cared about your JSON. So a customer named </script> would close the tag early, everything after it would be parsed as HTML, and now you're having a very bad day. And </ isn't the only trap: a name containing <!--<script> flips the parser into a mode where the real </script> no longer closes anything, so the tag swallows the rest of the payload and the call dies with a syntax error. The cure for both is to write every < in the JSON as \u003c. It means the same character inside a JavaScript string, and the HTML parser never sees a < at all. The exercise test tries both names, because of course it does.

Native → web. Going the other way, the shell dispatches a DOM event on window (iOS evaluateJavaScript, Android evaluateJavascript; yes, the capitalization differs, because mobile platforms like to keep you on your toes):

window.dispatchEvent(new CustomEvent('brew:native', {detail: {type: 'push-token', token: 'a1b2…'}}))

and the page handles it with a perfectly ordinary Datastar attribute, on a <div> that every page renders:

<div data-on:brew:native__window="evt.detail.type === 'push-token'
    && ($pushToken = evt.detail.token,
        @post('/native/device', {filterSignals: {include: /^pushToken$/}}))"></div>

It's one expression: && guards, and the comma operator sequences the assignment and the post. That's the lesson's style, not a requirement. A ;-separated version, or an if block, works just as well in data-on: we ran if (…) { $pushToken = …; $count = $count + 1 } against the v1.0.4 bundle to be sure. Datastar splits at ; only for attributes that return a value, such as data-computed, so that it can return the last statement.

Is that attribute key even legal? We didn't want to guess, so we went and read Datastar's source. The attribute key is split at the first colon (on / brew:native). The __window modifier puts the listener on window. And data-on leaves the event name alone, apart from an optional __case modifier, so the listener is for brew:native exactly. evt is the event. Setting $pushToken and posting means the token arrives at POST /native/device as an ordinary signal, read by the same read-signals as everything else, and stored as :devices {sid token} by a :register-device command. The filterSignals option keeps the rest of the page's signals (the draft, the name) out of that request. And this is the punchline of the whole bridge, so let me say it plainly: from the server's side, native features are just more signals and commands. There is no second architecture hiding in here.

Security, quickly, because this one bites: only ever load your own origin in the shell. A message handler accepts messages from whatever page is loaded, and an Android addJavascriptInterface object is visible to every page and frame in that web view. Every single one. Send other URLs to the system browser. On iOS, a handler can also check message.frameInfo.securityOrigin before acting.

Let's step through a session. The left side is the phone; the right side is whatever crossed the wire or the bridge on that step.

The payoff: "Ada, your order #1 is ready."

Here's where it gets fun. Part 6 already sends Ada her whole view whenever her order changes. The shell adds exactly one thing: when an order turns :ready, her stream also carries a bridge script. And the diff that decides who to notify is a pure function of the old and new state, like everything else in this series. (I keep saying "like everything else." I'm going to keep saying it, because it keeps being true.)

(defn ready-notifications [old new]
  (for [{:keys [id sid customer status]} (vals (:orders new))
        :when (and (= :ready status) (not= :ready (get-in old [:orders id :status])))]
    {:sid sid :message (str customer ", your order #" id " is ready.")}))

(defn watch! [!state hub]
  (add-watch !state ::live
    (fn [_ _ old new]
      (when (not= old new)
        (broadcast! hub !state)
        (doseq [{:keys [sid message]} (notify! hub (ready-notifications old new))]
          (when-let [token (get-in new [:devices sid])]
            (push! token message)))))))

notify! sends a bridge-script ({:type "notify" … :haptic "success"}) to every open stream of that sid. That's every tab of that customer, because a notification is per customer, unlike the draft. It returns the notifications that found no open stream, and those fall back to push!.

So Sam taps Advance on order #1 for the second time, and Ada's phone gets the re-rendered view (status "ready") plus a success haptic and a notification banner. Now count what that took. No new screen. No new API endpoint. No new app version. Zero, zero, zero. With one condition, and I want to draw a big red circle around it: the installed shell has to already know the "notify" message. That condition is the real boundary of this architecture. Everything else in this post is commentary on it.

And now the honest part, because I promised you one. A web view's stream only runs while the app is in the foreground. Part 6's rule (a hidden page closes its @get) applies, and iOS suspends backgrounded apps anyway, with all the sentimentality of a bouncer at closing time. So for "your order is ready" while the phone is in Ada's pocket, the server must send a real push notification (APNs on iOS, FCM on Android) to the token it got through /native/device. That's the fallback in watch!: when notify! finds no open stream for Ada, the toy's push! prints what a real server would send to APNs or FCM for her registered token. It's the one place where the server talks to a platform service instead of a page.

And the foreground has its own trap. Mobile networks love a half-open connection: the socket looks alive, nothing arrives, and nobody raises an error. Datastar reads a stream with no read timeout, so the page waits, patiently and forever, and the server's :on-close can take minutes to fire. Meanwhile notify! sees an open stream for Ada, sends "your order is ready" down it, and skips the push. Into the void it goes. The usual fix is a heartbeat: the server patches a counter every few seconds, and a small timer on the page shows a "reconnecting" banner after one missed beat and reopens the stream after two. (A heartbeat also stops proxies that cut idle connections after a minute or so from cutting yours.) None of that is Datastar-specific, so building it is an exercise for the reader.

What ships without a release

Here's the rule, and it's short enough to write on your hand: anything that is HTML, signals, routes, path rules, or a bridge message the installed shell already understands ships with a deploy. Anything that needs new native code ships with a store release. That's it. That's the rule. The /1 in BrewShell/1 is there so the server can tell which messages a given install understands, and send a "scan-qr" message only to shells that can actually handle it. (Remember when I said to hang onto that version number? This is why.)

Now let's talk about Apple, because somebody always brings up Apple, usually in a hushed voice, usually with a story about a friend of a friend whose app got rejected. Apple's guidelines bear on this in two places, and it's worth actually reading them instead of running on folklore. Guideline 4.2 Minimum Functionality says "your app should include features, content, and UI that elevate it beyond a repackaged website," and 4.2.2 says apps shouldn't primarily be "web clippings, content aggregators, or a collection of links." A shell that only loads a URL is precisely the thing 4.2 describes. Native navigation, push notifications, haptics and real native features are what make the difference. Then there's Guideline 2.5.2, which says apps may not "download, install, or execute code which introduces or changes features or functionality of the app." Web content in a WebKit view is how every hybrid app works (2.5.6 requires WebKit for browsing the web, and 4.7 explicitly allows HTML5 mini apps under conditions). But using server deploys to turn the app into something different from what was reviewed? That is exactly what 2.5.2 is about. Ship new screens of the same app, not a new app. (Quotes from Apple's App Review Guidelines, checked October 2026. Review is a human judgment, made by humans, on a schedule you don't control, so treat all of this as risk to manage, not a rule you can lean on.)

What you give up

I'd be lying if I told you this was free, and you'd find out anyway, probably at the worst possible moment. The trade-offs are what decide whether it fits:

So when should you keep React Native (or go fully native)? Offline-first apps. Apps whose core is rich touch interaction (maps, editors, media). Apps with heavy on-device computation. And teams whose real product is the mobile client, with the server as a data API. If that's you, more power to you; carry on. Brew Board is none of those. It's a set of forms and lists over shared, live server state, which happens to be the exact shape where sending HTML wins most clearly.

If you want something in between, look at Hyperview, from Instawork. It keeps the server-driven model but drops the web view entirely. The server sends Hyperview XML (HXML), a hypermedia format designed around mobile UI patterns, and a React Native client renders it with native components. Its pitch is the same as ours ("update your apps instantly by deploying your backend"), and the price is a new markup language instead of HTML. It's worth a look when native controls matter more to you than reusing the web page.

What the series built

Let's step back and look at what the native app actually needed from the server. A predicate on the User-Agent. A JSON file of path rules. One route for the push token. And a pure function deciding who to notify. That's the whole list. Every screen it shows came from parts 1–6: hand-written SSE events (part 2), signals for what the user is doing (part 3), the server owning everything shared (part 4), a small route table of queries and commands (part 5), and one stream per page that re-renders the whole view and morphs it in (part 6). The mobile app is the web app, plus a shell. I told you it was a dirty little secret.

The code lives in clojure/src/lesson07/native_shell.clj (ns lesson07.native-shell), with tests in clojure/test/lesson07/native_shell_test.clj. The demo requests the same page with and without BrewShell/1, fetches /native/path-config, posts a push token, and advances one order to ready while Ada's stream is open (watch a bridge script come down it) and another after her stream has closed (hello, push fallback): cd clojure && clojure -M -m lesson07.native-shell. Reference shells are in native/ios and native/android (see native/README.md; they aren't built in CI).

Your turn. The exercise track for this part is shell-request (the predicate shell-request?), bridge-script and ready-notifications. Hints are on the exercises page, for when you get stuck. You'll be fine.

cd clojure && clojure -M:ex -d ex -n exercises.lesson07.native-shell-test

If you remember one paragraph, make it this one. A native shell is a stack of web views showing your server's routes, plus navigation rules it downloads, plus a bridge: native → web as DOM events that become signals, web → native as scripts the server sends. Screens, layouts, and anything the installed shell already understands ship with a deploy. New native capabilities ship with a release, and the shell version in the User-Agent tells the server which is which. You give up offline use, finger-driven interaction, and some platform polish, so it fits apps made of forms and lists over live server state. Which describes more apps than you'd think.