A major feature of Outboard, my iOS app written with ClojureScript on React Native, is the ability to add places to your lists from a share extension inside Apple’s Maps, Google Maps, Yelp, or other apps that supply URLs. React Native ships with AsyncStorage, a LocalStorage analog for iOS and Android apps, but on iOS, that data is sandboxed to the app alone. To share between an app and an extension, you need to use App Groups, and roll your own persistence layer to interact with it. In this article, I’ll explain how to do that. You should first have an App Group set up on the developer portal and have an entitlement to it added both your app and its extension; you can follow this guide to get to that point.

The native DataManager

First we’ll implement the native persistence layer that will write keys to the UserDefaults object for our app group; I’ll do that in Swift. It’s a simple class with two functions, one to read and one to write.

@objc(DataManager)
class DataManager : NSObject {
  let defaults = UserDefaults.init(suiteName: "group.com.your.app.GroupName")

  @objc func loadData(_ key: NSString, callback: @escaping RCTResponseSenderBlock) -> Void {
    let data = defaults?.string(forKey: key as String)
    callback([NSNull(), data as Any])
  }

  @objc func saveData(_ key: NSString, data: NSString) -> Void {
    defaults?.set(data, forKey: key as String)
  }
}

Note the @objc macro calls here; we need to eventually provide an ObjC bridge for ReactNative to talk to it. Our loadData function takes a key and a callback function; that function is what we’ll provide in ClojureScript later to handle reads. The saveData function simply writes without any callback. We haven’t done anything with errors in either function; you should build that in to handle your specific use case. The aforementioned ObjC bridge goes in DataManagerBridge.m:

#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(DataManager, NSObject)
RCT_EXTERN_METHOD(loadData:(NSString *)key callback:(RCTResponseSenderBlock)callback)
RCT_EXTERN_METHOD(saveData:(NSString *)key data:(NSString *)data)
@end

The ClojureScript layer

The bare code for saving/loading is also two simple functions, mediated by React Native’s NativeModules.

(def data-manager
  (.-DataManager (.-NativeModules (js/require "react-native"))))

(defn get-item
  [key cb-fn]
  (.loadData data-manager key
    (fn [err val]
      (let [val* (when val (cljs.tools.reader.edn/read-string val))]
        (cb-fn err val*)))))

(defn put-item
  [key val]
  (.saveData data-manager key (pr-str val)))

A few things to note here:

  • You may need to provide externs, or use something like cljs-oops for the .-DataManager call under advanced compilation
  • I’m using pr-str and read-string to ednify the data I’m saving
  • In real life, I actually use core.async in get-item to avoid callback hell, but I’m providing the minimum viable implementation here

At this point, you should be able to put items into storage from the REPL and retrieve them.

Connecting to Re-Frame

The real power from this approach comes at the final Re-Frame step, where I save my app-db to UserDefaults on every change automatically:

(defn save-state
  "Save the state of the db"
  [db _]
  (put-item :saved-app-state db))

(def validate-spec-and-save
  (if goog.DEBUG
    [(after save-state)
     (after (partial check-and-throw ::db/app-db))]
    [(after save-state)]))

;; ...

(reg-event-db
  :add-something
  validate-spec-and-save
  (fn [db [something]]
    (assoc db :somethings something)))

You’ll need to make sure that you initialize your state from (get-item :saved-app-state (fn [_ val] (dispatch-sync [:initialize-db val])) in your app startup as well.

Now, every time I call (dispatch [:add-something :something]), that change to my app-db will also immediately be persisted locally; any changes made from the share extension will reflect in the app and vice-versa.

Conclusion

Setting up app groups and persisting to UserDefaults can be a fiddly experience in XCode, but the actual code to do so is straighforward. With Re-Frame Interceptors to handle automatically persisting your data in the background for you, you can treat your Clojurescript-written app and extensions as writing and reading from the same app-db.