Asynchronous support

6 minutes read

Applies to 1.1.35+
State DRAFT

The "hypereact" state manager supports asynchronous programming through promises. Specifically the features that can be used with async/await based pattern are:

  • "dispatch" function in store manager thus enabling support for asynchronously generated actions
  • persistence-related reducers "rehydrate" function thus enabling lazy (re)initialization of the state

Async dispatch

The "dispatch" can be invoked with an action object/instance or a Promise that will resolve to an action. If a Promise is detected the dispatch function returns a Promise itself thus allowing to await in async functions.

// simulate an asynchronous function that returns a result in the future
const delay = (result: any = null): Promise<any> => {
  return new Promise(function (resolve, reject) {
    setTimeout(function () {
      resolve(result);
	}, 100);
  });
};

// assuming store manager has been coonfigured (example from the getting started code) 
const storeManager = StoreManager.getInstance();

(async () => {
  // dispatch a generic Redux action
  const jsonAction = {
    type: "UNMANAGED_ACTION"
  };
  await storeManager.dispatch(delay(jsonAction));

  // dispatch a class typed action (example from the getting started code)
  const action = new SetExampleMessageAction("...");
  await storeManager.dispatch(delay(action));
})();

If the action passed as Promise is rejected, the "dispatch" will reject consistently the returned Promise. In the async/await pattern the exception of the async action generator is propagated by the dispatch function and can be catched as usual.

Async rehydrate

The re-hydration of state slices might require data fetching from the server or other asynchronous tasks to be executed before having the full data to be dispatched. Because the process of re-initializing the state should occur before the state is "ready", the store manager of "hypereact" provides two main features to handle such scenarios:

  • "isReady" and "waitUntilReady" functions for respectively probing or (a)waiting the readiness of lazy initialization of the state
  • non-blocking async support for the persistence-enabled reducers "rehydrate" executed when the store state is initialized or a new reducer is added

This feature is implemented through an automated two-steps state flow that leverage an internal lazy-action dispatch with type prefixed with "..." that is described as follows for the store initialization:

  1. the store is initialized with a lazy-re-hydrating reducer
  2. Redux dispatches the initialization action (e.g. with type "@@INIT")
  3. the reducer "rehydrate" function is executed and returned value is captured and
    1. if the returned value is not a promise, the reducer-managed state slice is set with the returned sync re-hydrated state (initialization ends and store manager is ready)
    2. if the returned value is a promise, the reducer-managed state slice is set with coded initial state (initialization ends but store manager and state slice is not "ready" yet) and
      1. when the promise is resolved a new internal action (e.g. with type prefixed "...@@INIT") is dispatched to reduce the state slice to the lazy-defined re-hydrated state (the store manager and state slice is "ready")

Because the "rehydrate" functions should never throw any exception but should handle eventual issues internally. In the asynchronous scenario, the store manager does handle the promise rejection keeping the coded initial state as a fallback.

// simulate an asynchronous function that returns a result in the future
const sleep = (ms: number, result: any = null): Promise<any> => {
  return new Promise(function (resolve, reject) {
    setTimeout(function () {
      resolve(result);
    }, ms);
  });
};

interface UserState {
  authenticated: boolean;
  details: {
    name?: string
  }
}

interface PersistedUserState {
  authenticated: boolean;
}

const initialState: UserState = {
  authenticated: false,
  details: {}
};

// simulate a function that is getting user details (name) from server
// assume details are returned based on the authentication tokens/cookies
// and not expired tokens throw make request fail and throw expection
const fetchUserName = async () => {
  await sleep(2000);
  return "John";
};

// custom persistent reduceable reducer with asynchronous rehydration
export class UserReducer extends PersistentReduceableReducer<UserState> {
  // for security/privacy we only store latest authentication status (not details)
  dehydrate(state: UserState) : PersistedUserState {
    return { authenticated: state.authenticated };
  }

  // rehydration includes user details fetching from the server if he/she was authenticated
  async rehydrate(state: UserState, data: PersistedUserState): Promise<UserState> {
    // if user was authenticated
    if (data.authenticated) {
      try {
        const name = await fetchUserName();
        return {
          authenticated: true,
          details: {
            name
          }
        };
      } catch(e) {
        // tokens/cookies expired or not anymore valid
      }
    }
    // initial (not authenticated user) state
    return state;
  }
}

State "readiness"

The asynchronous support introduces the need to get the "readiness" of the root state and/or specific state slices. A state (root or slice) is "ready" when all the eventual slices are not handled by lazy-rehydrating reducers or eventual lazy-rehydration is not needed (no persisted state) or finished (successfully or not).

The library provides:

  • "isReady" function that returns true when no asynchronous state slices are waiting to be resolved with re-hydrated values
  • "waitUntilReady" function that resolves when all the eventual asynchronous state slices has been resolved (or rejected) thus the state is ready

Both the function accepts a slice key as argument to get or wait for readiness against a specific state slice and reducer re-hydration.

// assuming the store manager has been configured with a lazy-rehydrating reducer
const storeManager = StoreManager.getInstance();

// check if store state is ready (and all slices)
if (storeManager.isReady()) { console.log("root state is ready"); }

// check if the needed slice state is ready
if (storeManager.isReady("user")) { console.log("user state slice is ready"); }

// wait until entire store state is ready
(async () -> {
  await storeManager.waitUntilReady();
  console.log("do whatever you need to do")
});

// wait until the needed slice state is ready
(async () -> {
  await storeManager.waitUntilReady("user");
  console.log("do whatever you need to do now that the user state slice is ready")
});

Advanced flows

To support complex and advanced flows, the store manager injects additional arguments into the rehydrate/dehydrate functions:

  • manager instance to access convenient methods during re-hydration or de-hydration;
  • the initially persisted root state (sealed) for accessing data across slices eventually needed (only in rehydration).
// assume a slice has been created to store user configuration (that depends on authentication)
export class ConfigurationReducer<T> extends ReduceableReducer<T> implements IHydratableReducer<T> {

  // dehydration persists data only if the user is authenticated
  dehydrate(state: ConfigurationState, manager: StoreManager) {
    const userState: UserState = manager.getState("user");
    if (userState.authenticated === true) {
      return state;
    }
    return null;
  }

  // rehydration might fetch the configuration from server only if the user is still authenticated 
  async rehydrate(state: ConfigurationState, data: any, root: any, manager: StoreManager): T {
    // the root argument provides access to all slices persisted data as a generic object (the store is not yet ready!)
    const userPersistedState: any = root["user"];
    // if the user were authenticated, we wait until the user state slice is ready (and authentication is checked)
    if (userPersistedState.authenticated === true) {
      // we wait for authentication checking (through user state readiness from examples above)
      await manager.waitUntilReady("user");
      // if user is still authenticated, we retrieve the user configuration from the server
      const userState: UserState = manager.getState("user");
      if (userState.authenticated === true) {
        const configState: ConfigurationState = await fetchUserConfiguration();
        return configState;
      }
    }
    return state;
  }
}