<- Back to Blog

Loosely synchronize your (Zustand) stores in multiple Tauri processes

A simple solution to synchronize your JavaScript/React stores in multiple Tauri processes (windows).

costa-alexoglou
Costa Alexoglou ·
Loosely synchronize your (Zustand) stores in multiple Tauri processes

Contents#

Multi-window state management in Tauri and React#

Below I will explain how you can easily synchronize your React store, that is Zustand (used in the examples), Redux or any store you use, in multiple Tauri processes (windows). If you faced this problem before and want a relatively simple solution, this post is for you.

The problem#

Early in the development of Hopp, we decided to use Zustand for state management. Zustand is a lightweight state management library that is easy to use and has a small bundle size. Also its API is really nice and easy to understand. But there was one problem.

The state of the app is living in the main process of the window. This is great for single-window apps, but what happens in multi-window apps that want to consume the same state?

Hopp Multi-window example
Hopp Multi-window example

At Hopp for example, we run one window for the main control panel of our app, and then have a separate window for screen-sharing and remote control, with some of the state needed for both windows, and of course synced, as this might changed and it needs to be reflected in the other window.

To overcome this problem, we needed to make a decision, on whether to keep the state on the backend (Rust), and there is great material in the form of blogposts around this topic, or be creative.

Choose were state lives
Literally how I felt when deciding what to do with multi-window state management

While holding the state on the backend sounded solid, I really wanted to avoid losing all the reactivity that comes out of the box with Zustand (or Redux or any other state management library). Besides that, every component would be polluted with async calls for fetching and setting the state.

The solution, a loosely synced state#

Before we dive into the solution, let's take into consideration one crucial decision we made.

The state would be loosely synced

To avoid excessive complexity, we decided that the state would be loosely synced.

As I was searching for similar packages, I found a great package from Klarna/Electron-Redux, which also re-affirms the idea of loosely syncing the state.

But what does it mean to be loosely sync state? In simple terms, it means that there might be brief moments where our windows are not perfectly synchronized but are close enough for practical purposes.

This trade-off is acceptable, and the limitation is mainly because state in React is meant to work in a sync way, and to delegate the state so it's not loosely synced, we would need to orchestrate the state with a common back-end and this means async calls (or blocking the main thread 🙃).

How to implement it#

The mechanism is really simple, so we decided not to ship it as a package, but rather as a simple example.

import { create } from "zustand";
import { emit, listen } from "@tauri-apps/api/event";
import { isEqual } from "lodash-es";

// 1. Define a simple state and actions for the example.
// This state will be shared across windows.
const useStore = create((set) => ({
  count: 0,
  user: null,

  // Actions
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  login: (name) => set({ user: { name } }),
  logout: () => set({ user: null }),
}));

// --- STATE SYNCHRONIZATION LOGIC ---

/**
 * This flag prevents an infinite loop of updates. When a window receives an
 * update from another window, it sets this flag to `true` before applying
 * the new state. This ensures that the `subscribe` function below doesn't
 * immediately re-broadcast the same state change it just received.
 */
let isProcessingUpdate = false;

/**
 * PHASE 1: BROADCASTING STATE CHANGES
 *
 * This function runs every time the state in the current window changes.
 * It sends the new state to all other windows.
 */
useStore.subscribe((currentState, previousState) => {
  console.log("Updating the state!!!");
  if (isProcessingUpdate) {
    return;
  }

  // We use `isEqual` for a deep comparison to avoid unnecessary updates
  // for objects and arrays, which might otherwise trigger a change even
  // if their contents are identical.
  if (!isEqual(currentState, previousState)) {
    emit("store-update", currentState);
  }
});

/**
 * PHASE 2: LISTENING FOR STATE CHANGES
 *
 * This listener runs whenever another window broadcasts a 'store-update' event.
 * It receives the new state and updates the current window's store.
 */
listen("store-update", (event) => {
  const newState = event.payload;

  if (!isEqual(useStore.getState(), newState)) {
    isProcessingUpdate = true;
    // Here we could do deep merging,
    // but for the sake of simplicity,
    // we just replace the state
    useStore.setState(newState);
    isProcessingUpdate = false;
  }
});

/**
 * PHASE 3: INITIAL STATE HYDRATION FOR NEW WINDOWS
 *
 * This logic ensures that when a new window opens, it gets the most
 * up-to-date state from an existing window.
 */

// A flag to ensure we only hydrate the state once on initial load.
let hasHydrated = false;

// When a new window opens, it immediately requests the current state.
emit("get-store-request");

// Existing windows will listen for this request and respond with their current state.
listen("get-store-request", () => {
  emit("get-store-response", {
    state: useStore.getState(),
  });
});

// The new window listens for the response and hydrates its own state.
listen("get-store-response", (event) => {
  if (!hasHydrated) {
    const newState = event.payload.state;

    // We set the processing flag here as well to prevent the `subscribe`
    // function from immediately broadcasting this initial state.
    isProcessingUpdate = true;
    useStore.setState(newState);
    isProcessingUpdate = false;
    hasHydrated = true;
  }
});

export default useStore;

To understand how it works, you can simply check the following sequence diagram. The top half part, is how the sync works when a new window is created and needs to "re-hydrate". The bottom half, is how the sync works when the state changes and the state needs to sync across windows.

Sequence diagram
Sequence diagram

Things to be improved#

We talked about loosely syncing, but technically speaking, we may drop events while syncing state across windows.

There are some cases that may result in a race condition, but so far we have not encountered any that is a deal breaker in production with this way.

An ideal solution would probably involve the state being kept in the backend (Rust), as a JSON blob, and then a lock process to update state per window. This way we would avoid any race condition, but the problem then is the async calls, and how they will pollute the code with async/await calls. If there is any easy way to do this, please let me know at my Twitter/X account @costasAlexoglou.

Example codebase#

If you are interested in the codebase of this example, you can find it here.

Hopp Multi-window example
Hopp Multi-window example

Ditch the frustrating "Can you see my screen?" dance.

Code side-by-side, remotely.

Get started

Conclusion#

You reached the end of the post! Hopefully you found it useful, and I am sure you are building amazing apps with Tauri! If you want feedback about your app feel free to reach me at my Twitter/X account @costasAlexoglou or email me directly at costa@gethopp.app.

tauri
state
zustand
nodejs
GET STARTED

Ready for a better way to pair?

Imagine never losing your flow to screen sharing lag again. Picture pairing sessions that feel as smooth as coding locally. You're not just getting a tool—you're unlocking the collaboration experience that separates world-class developers from the rest.