Skip to content

Exploring options for bidirectional state management

Justin Bush edited this page Jun 8, 2024 · 5 revisions

Goal

The goal is to minimize as much template code as possible. The ideal scenario is only having to declare state variables once, leaving our code handle the rest. I.e.

const [total, setTotal] = useState<number>(0); // ts

↑ ↓

@State var total: Double // swift

Progress

 

Vanilla Setup

We have a variable, total, that we want to keep synchronized with Swift variable, @State var total: Double.

Let's start with introducing a basic setup for this functionality:

 

React → Swift

Using the following code, we will update the Swift state variable upon changes made to the React state variable.

global.d.ts

Declare total WKScriptMessageHandler at global scope.

declare global {
  interface Window {
    webkit: {
      messageHandlers: {
        total: {
          postMessage: (value: number) => void;
        };
      };
    };
  }
}

export {};

page.tsx

Notify WKScriptMessageHandler of update made to React state variable upon value change of total.

import React, { useEffect, useState } from 'react';

const MyComponent: React.FC = () => {
  const [total, setTotal] = useState<number>(0);

  // on change of total, notify Swift of new value
  useEffect(() => {
    notifySwiftOfChange(total)
  }, [total]);
  
  const updateTotal = (newTotal: number) => {
    setTotal(newTotal);
  };

  // notify Swift of new value
  const notifySwiftOfChange = (value: number) => {
    if (window.webkit?.messageHandlers?.total) {
      window.webkit.messageHandlers.total.postMessage(value);
    } else {
      console.warn("Message handler 'total' is not available.");
    }
  };

  return (
    <div>
      <p>Total: {total}</p>
      <button onClick={() => updateTotal(total + 1)}>Increment</button>
    </div>
  );
};

export default MyComponent;

Milestone Goal

Shrink this boilerplate code down to:

const [total, setTotal] = useCustomState<number>(0);

 

Swift → React

Using the following code, we will update the React state variable upon changes made to the Swift state variable.

page.tsx

import React, { useEffect, useState } from 'react';

// declare exposed type
declare global {
  interface Window {
    updateTotal: (value: number) => void;
  }
}

const MyComponent: React.FC = () => {
  const [total, setTotal] = useState<number>(0);

  const updateTotal = (newTotal: number) => {
    setTotal(newTotal);
  };

  // expose function to browser
  useEffect(() => {
    window.updateTotal = updateTotal;
  }, []);

  return (
    <div>
      <p>Total: {total}</p>
      <button onClick={() => updateTotal(total + 1)}>Increment</button>
    </div>
  );
};

export default MyComponent;

Milestone Goal

Shrink this boilerplate code down to a custom hook:

useExpose(updateTotal);

 

Combined Solution

Not sure if any of this is possible; ideally, we're looking to satisfy at least one of two requirements:

  • Solutions that add a layer to vanilla React without affecting the underlying functionality
  • Solutions that do not add any new lines of code

 

Potential solutions could include:

1. Decorators

Note: Decorators are limited in tsx.

@expose
const [total, setTotal] = useState<number>(0);

@expose
const updateTotal = (newTotal: number) => {
  setTotal(newTotal);
};

2. Custom Hooks

Point to updateTotal in the useCustomState hook declaration.

const [total, setTotal] = useCustomState<number>(0, updateTotal);

const updateTotal = (newTotal: number) => {
  setTotal(newTotal);
};

3. Custom Types

expose would be equivalent to const, but with the functionality described above.

expose [total, setTotal] = useState<number>(0);

expose updateTotal = (newTotal: number) => {
  setTotal(newTotal);
};