Port Elmish to Typescript: A Conversation with ChatGPT

My custom software team recently implemented an “export” function on my current project. The project is roughly in the CMS space, where users can upload source-of-truth documents as input. Then, the system aggregates these documents over time and handles change tracking and publishing of the content. To speed up some workflows, we created an “export” feature to export different types of documents for subsequent editing. These exports often involve processing many files, so we wanted to create the export file only on demand from the user. Once created, though, we wanted subsequent downloads to be quick.

The backend was conceptually a problem of identifying a “state of the world” to export and caching the exported artifacts with that state of the world as the cache key. The feature is a great candidate for something like websockets/server-sent events to notify the requesting client when the export is ready. However, for now, we went with a basic HTTP polling approach. Of course, different exports had different user experience (UX) requirements. Some were modal windows, and we wanted the modal locked open while the file is generated and downloaded. Others were links that needed to display as spinners while the export was in progress but didn’t otherwise block any other interactions.

The Problem

This kind of async state management can quickly spiral into a big, messy headache in front-end code bases. As we worked through triggering the polling logic and keeping the right UI elements in sync, we found ourselves playing a game of whack-a-mole while simultaneously filling up a plate of spaghetti (a situation almost as messy as that metaphor).

Around this time, I had been playing around with Feliz and Elmish to get a feel for what front-end development using F# might look like. I had previously worked on an F# project here at Atomic and found a lot to love in the F# language and ecosystem. As it turns out, Feliz and Elmish are a great option for creating a typesafe frontend project in the style of Elm, with a little dose of F# practicality mixed in. In particular, I loved working with Elmish’s withSubscription mechanism. I liked it so much, that I thought a similar mechanism might help clean up our polling export feature.

So, to find out whether that hunch was correct, I turned to ChatGPT. I hoped to get a port of the Elmish feature I liked into Typescript quickly. Here’s how the conversation went.

Prompt 1

Implement useStore<Msg, Model>, in typescript, similar to the store in Elmish.

// Define your model (state) and messages (actions)
interface Model {
  count: number;
}

type Msg = 
  | { type: 'Increment' }
  | { type: 'Decrement' };

// Define a "reducer" function that takes the current state and a message, and returns a new state
function update(model: Model, msg: Msg): Model {
  switch (msg.type) {
    case 'Increment':
      return { ...model, count: model.count + 1 };
    case 'Decrement':
      return { ...model, count: model.count - 1 };
    default:
      return model; 
  }
}
// Define a React context for our store
const StoreContext = createContext<{ model: Model; dispatch: (msg: Msg) => void;
}>({
  model: { count: 0 },
  dispatch: () => {}
});

type StoreProviderProps = {
  children: ReactNode;
  initialModel: Model;
};

// Create a StoreProvider component
export const StoreProvider = ({ children, initialModel }: StoreProviderProps) => {
  const [model, dispatch] = useReducer(update, initialModel);

  return (
    
      {children}
    
  );
};

// Custom hook to allow easy access to the store
export const useStore = () => {
  const context = useContext(StoreContext);
  if (context === undefined) {
    throw new Error('useStore must be used within a StoreProvider');
  }
  return context;
};

This is a basic step in the right direction. We’ve got an extremely basic Redux-flavored structure here, and it’s coupled to one Msg / reducer. Let’s keep refining.

Prompt 2

Add a subscribe function that behaves like Elmish.

type Subscription = (dispatch: React.Dispatch) => () => void; // Subscriptions return a tear-down function

type EnhancedStoreProviderProps = {
  children: ReactNode;
  initialModel: Model;
  subscriptions?: Subscription[]; // Add a subscriptions prop
};

// Modify your StoreProvider to accept subscriptions
export const StoreProvider = ({ children, initialModel, subscriptions = [] }: EnhancedStoreProviderProps) => {
  const [model, dispatch] = useReducer(update, initialModel);

  // Handle subscriptions
  useEffect(() => {
    const unsubscribes = subscriptions.map((subscribe) => subscribe(dispatch));
    return () => {
      unsubscribes.forEach(unsubscribe => unsubscribe());
    };
  }, [subscriptions]); // Dependencies array empty means this runs once on mount and never re-subscribes

  return (
    
      {children}
    
  );
};

This has added the idea of subscriptions, but it does have the auto start/stop behavior that makes Elmish so powerful. This code starts subscriptions but then never changes them until the component unmounts. Let’s keep going.

Prompt  3

Make useStore take in the reducer and a subscriptions function like Elmish, and manage the creation and cancelation inside the store, like Elmish

// Subscription function type (returns a cleanup function)
type Subscription = (dispatch: Dispatch) => () => void;

// Reducer function type
type Update<Model, Msg> = Reducer<Model, Msg>;

// Define a hook that takes the initial state, an update function, and subscriptions
function useStore<Model, Msg>(
  initialModel: Model,
  update: Update<Model, Msg>,
  subscriptions?: (model: Model, dispatch: Dispatch) => Subscription[]
) {
  const [model, dispatch] = useReducer(update, initialModel);
  const subscriptionRef = useRef<ReturnType[]>([]);

  // Handle the side effects
  useEffect(() => {
    if (subscriptions) {
      // Reset subscriptions if they are provided
      // Cleanup previous subscriptions before setting new ones
      subscriptionRef.current.forEach((unsubscribe) => unsubscribe());
      subscriptionRef.current = subscriptions(model, dispatch).map((subscribe) => subscribe(dispatch));
    }

    // Cleanup function that runs when the component is unmounted or when dependencies change
    return () => {
      subscriptionRef.current.forEach((unsubscribe) => unsubscribe());
    };
  }, [model, dispatch, subscriptions]); // Re-subscribe if model, dispatch or subscriptions function change

  return { model, dispatch };
}
// Example of subscription function
const timeSubscription = (_model: Model, dispatch: Dispatch): Subscription[] => [
  () => {
    const intervalId = setInterval(() => {
      dispatch({ type: 'Tick' });
    }, 1000);

    return () => {
      clearInterval(intervalId);
    };
  }
];

This is a bit better. This is the first time that useStore has been generic with its message and model type, which is what we want eventually. Now, when the subscriptions array that gets passed in changes, at least the old subscriptions will be canceled. That said, there’s still no mention of anything like Elmish’s withSubscription.

Prompt 4

Add IDs to the subscription array returned by timerSubscription, and make the store diff the previous subscriptions with the current subscriptions to determine which subscriptions to start and which to stop.

// A Subscription should now have an ID and a function that returns a cleanup function
interface Subscription {
  id: string;
  create: (dispatch: Dispatch) => () => void;
}

// Reducer function type
type Update<Model, Msg> = Reducer<Model, Msg>;

// Define a hook that takes the initial state, an update function, and subscriptions
function useStore<Model, Msg>(
  initialModel: Model,
  update: Update<Model, Msg>,
  subscriptions?: (model: Model, dispatch: Dispatch) => Subscription[]
) {
  const [model, dispatch] = useReducer(update, initialModel);
  const subscriptionRef = useRef<{ [key: string]: () => void }>({});

  // Handle subscriptions with diffing
  useEffect(() => {
    if (subscriptions) {
      const currentSubscriptions = subscriptions(model, dispatch);
      const newSubscriptions: typeof subscriptionRef.current = {};

      // Starting new subscriptions and diffing with existing ones
      currentSubscriptions.forEach((subscription) => {
        const { id, create } = subscription;

        if (!subscriptionRef.current[id]) {
          // Subscription with this ID does not exist, so start it
          newSubscriptions[id] = create(dispatch);
        } else {
          // Subscription with this ID exists, no need to re-create, just transfer over
          newSubscriptions[id] = subscriptionRef.current[id];
        }
      });

      // Stopping subscriptions that are no longer present
      Object.keys(subscriptionRef.current).forEach((id) => {
        if (!newSubscriptions[id]) {
          subscriptionRef.current[id]();
        }
      });

      subscriptionRef.current = newSubscriptions;
    }

    // Cleanup function
    return () => {
      Object.values(subscriptionRef.current).forEach(unsubscribe => unsubscribe());
    };
  }, [model, dispatch, subscriptions]); // Re-subscribe if model, dispatch, or subscriptions function change

  return { model, dispatch };
}

// Example of subscription function
const timerSubscription = (_model: Model, dispatch: Dispatch): Subscription[] => [
  {
    id: 'timer', // Unique ID for the subscription
    create: (dispatch: Dispatch) => {
      const intervalId = setInterval(() => {
        dispatch({ type: 'Tick' });
      }, 1000);

      // Return cleanup function
      return () => clearInterval(intervalId);
    }
  }
];

Ah, there we go! Now our subscriptions have logical identifiers, and the (still correctly generic) useStore hook is stopping subscriptions when they disappear from the array. We’re getting close now, but this isn’t something we can just drop into a Typescript or React app and use.

Prompt 5

Create a factory function that, given the update and subscription functions from above, returns a context provider and the corresponding useStore hook.

// Subscription and Update definitions
type Subscription<Model, Msg> = {
  id: string;
  create: (model: Model, dispatch: Dispatch) => () => void;
};

type Update<Model, Msg> = Reducer<Model, Msg>;

// Factory function
function createStore<Model, Msg>(initialModel: Model, update: Update<Model, Msg>, subscriptions: (model: Model, dispatch: Dispatch) => Subscription<Model, Msg>[]) {
  // Create React context specific to this store with Model and Msg type
  const StoreContext = createContext<{
    model: Model;
    dispatch: Dispatch;
  }>({
    model: initialModel,
    dispatch: () => { /* */ },
  });

  // Create the context provider component
  const StoreProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
    const [model, dispatch] = useReducer(update, initialModel);
    const subscriptionRef = useRef<{ [key: string]: () => void }>({});

    // Effect for handling subscriptions with diffing
    useEffect(() => {
      const currentSubscriptions = subscriptions(model, dispatch);
      const newSubscriptions: { [key: string]: () => void } = {};

      currentSubscriptions.forEach(subscription => {
        const { id, create } = subscription;
        if (!subscriptionRef.current[id]) newSubscriptions[id] = create(model, dispatch);
        else newSubscriptions[id] = subscriptionRef.current[id];
      });

      Object.keys(subscriptionRef.current).forEach(id => {
        if (!newSubscriptions[id]) subscriptionRef.current[id]();
      });

      subscriptionRef.current = newSubscriptions;

      return () => {
        Object.values(subscriptionRef.current).forEach(unsubscribe => unsubscribe());
      };
    }, [model]);

    return (
      
        {children}
      
    );
  };

  // Create the useStore hook
  const useStore = () => {
    const context = useContext(StoreContext);
    if (context === undefined) {
      throw new Error('useStore must be used within a StoreProvider');
    }
    return context;
  };

  return { StoreProvider, useStore };
}

And there we go, now we have a function that we can call with a reducer and a subscriptions function. Additionally, it will return a provider to use as a parent component and a useStore hook that will hook into that context.

A Powerful Use of ChatGPT

This is a great example of one of the many ways something like ChatGPT can be a powerful tool in the development toolbox. You’ll notice that the initial prompt yielded a very different result than the fifth one, to the point that the first output was almost useless. But, with a clear idea of where I wanted things to go, it only took a few nudges to get the exact output I wanted.

Conversation

Join the conversation

Your email address will not be published. Required fields are marked *