DEV Community

Voltra
Voltra

Posted on

Where modern front-end frameworks fall short, and how to help them and yourself

When you need to communicate between pages you have a few options:

  • Use your framework's events capabilities
  • Have a global store and put something there
  • Use the query string to add information

All of these have downsides, and might not always work due to how routing and components are handled in your framework. You also have the issues of "should I change the data before or after changing routes?" and "will my change to the data be seen by my component?".

You have a ContactsCrud widget that contains rows of Contact items. You have a ContactPage component which displays the contact's details (which include the calls history). When on the ContactsCrud, if you click on the contact's phone number you want to go to the corresponding ContactPage and execute the code there that starts a new call (and displays the appropriate UI).

How to go about doing that... You can't events since the component isn't rendered yet and thus cannot respond to it, and if you were to listen the event on mount it'd be too late. You could use the global store, but then you have to write boilerplate every time you need something similar. Same goes with the query string, but it's even messier.

My solution? Message Passing / the Command Design Pattern.

We don't have access to (G)ADTs, so first we'll help typescript with our messages' definitions:

/**
 * Here we define both the messages' names
 * and their data payload. Note that we use
 * `{}` if we don't need data, just so we
 * can use equality comparisons in our implementations
 */
export interface MessagesPayloadMap {
  StartNewCall: {
    contactId: API.Id;
  };
  Test: {};
}

/**
 * We can use types that extend from this
 * to get god-tier autocompletion
 */
export type MessageType = keyof MessagesPaylodMap;

/**
 * Just a shorthand to get the payload for the
 * given message type
 */
export type MessagePayload<M extends MessageType> = MessagesPayloadMap[M];

/**
 * How our messages actually look like
 */
export interface Message<M extends MessageType> {
  name: M;
  data: MessagePayload<M>;
};

/**
 * The type of our enum-style object that contains
 * all the message factories
 */
export type MessageFactoriesEnum = {
  [M in MessageType]: (payload?: MessagePayload<M>): Message<M>;
}

/**
 * What we get when querying messages
 */
export interface MessageQuery<M extends MessageType> {
  message: Message<M>;

  /**
   * When querying, calling this function will
   * remove the message from the system
   */
  markAsCompleted: () => void;
}

/**
 * Type of the callbacks that process messages
 */
export type MessageProcessor<M extends MessageType> = (message: Message<M>, markAsCompleted: () => void) => void;
Enter fullscreen mode Exit fullscreen mode

With those type definitions in mind, we can get to the meat of things:

export const Messages: MessageFactoriesEnum = {
  StartNewCall: (data: MessagePayload<"StartNewCall">) => ({
    name: "StartNewCall",
    data,
  })
  Test: () => ({
    name: "Test",
    data: {},
  }),
};

export interface WorkQueue<M extends MessageType> {
  getQuery(): MessageQuery<M>[];

  /**
   * Processor might not be called (if the queue is empty)
   */
  query(messageProcessor: MessageProcessor<M>): void;

  /**
   * Processor might not be called (if the queue is empty)
   */
  queryLast(messageProcessor: MessageProcessor<M>): void;

  /**
   * Processor might not be called (if the queue is empty)
   */
  queryFirst(messageProcessor: MessageProcessor<M>): void;
};

export interface MessagePassing {
  emit<M extends MessageType>(messageType: M, payload: MessagePayload<M>): void;

  dispatch<M extends MessageType>(message: Message<M>): void;

  on<M extends MessageType>(messageType: M, messageProcessor: MessageProcessor<M>): void;

  off<M extends MessageType>(messageType: M, messageProcessor: MessageProcessor<M>): void;

  once<M extends MessageType>(messageType: M, messageProcessor: MessageProcessor<M>): void;

  queryLast<M extends MessageType>(messageType: M, messageProcessor: MessageProcessor<M>): void;

  queryFirst<M extends MessageType>(messageType: M, messageProcessor: MessageProcessor<M>): void;

  query<M extends MessageType>(messageType: M, messageProcessor: MessageProcessor<M>): void;
}
Enter fullscreen mode Exit fullscreen mode

And with this, we can get rid of our call headache:

  • Before navigating, just call mp.dispatch(Messages.StartNewCall({ contactId: 42 }))
  • On first render of your ContactPage, in whichever sub-component needs it, just call mp.queryLast(callback)

The bonus points for this approach are:

  • You can have multiple instances querying the messages (as long as only one marks them as completed)
  • Message queries are "independent" from actual messages in the queue (i.e. deleting them from the queue won't delete them from a query)
  • You can dispatch messages anywhere as long as you have the data to do so
  • You'll never need it on the server, so you can make it a client-only plugin for your framework

Latest comments (0)