Skip to content

Instantly share code, notes, and snippets.

@sushruth
Last active October 19, 2022 05:51
Show Gist options
  • Save sushruth/8e0482c151bc06a209790d12617a940c to your computer and use it in GitHub Desktop.
Save sushruth/8e0482c151bc06a209790d12617a940c to your computer and use it in GitHub Desktop.

Async function to Phasor state

Note: Phasor is the new Fetchable. It is a more general concept from physics and does not collide with other standard keywords in web development.

Introduction

Context

Imagine you have an async function, lets call this the runner -

export type UserSearchErrors = AuthError | QueueFullError | NetworkError;

async function searchUsers(searchTerm: string, skip = 0) {
  const results = await users()
    .search(searchTerm)
    .skip(skip)
    .fetch()
    .then((res) => res.json());

  // ... more logic to throw custom errors or return different data
  // example:
  //     const authError: AuthError = {...}
  //     if(condition) throw authError;

  return results.value;
}

You somehow want to use this in your @mwt/async-reducer-state based state. But it is currently a pain and cumbersome to do that. Here is where the proposed utilities come in.

The new asyncToPhasorState utility

You can use the new helper function asyncToPhasorState to easily create a partial reducer and a partial state for this searchUsers function. This is how you would do it -

// πŸ‘‰ 1. create your partial getters using the helper
const { getInitialState, getPartialReducer } = asyncToPhasorState(
  'UserSearch',
  searchUsers
);

// πŸ‘‰ 2. strongly typed initial and partial state for your phasor
export const userSearchInit = getInitialState<UserSearchErrors>();
export const userSearchPartial = getPartialReducer<
  GlobalState, // πŸ”† you will create this further below
  GlobalActions, // πŸ”† you will create this further below
  UserSearchErrors
>();

// πŸ‘‰ 3. export types for your partials
export type UserSearchActions = ActionsForPartial<typeof getPartialReducer>;
export type UserSearchState = typeof initialState;

Info:

  1. ActionsForPartial and StateForPartial are available to be imported from @mwt/async-reducer-state.

Now this partial state and reducer types can be used to construct the global state and actions types -

// πŸ‘‰ 4. use partials to construct global state and actions types
type GlobalState = UserSearchState; // & FooState & BarState & ...
type GlobalActions = UserSearchActions; // | FooActions | BarActions | TelemetryActions ...

We are now ready to add our partials to the global state when using createState like below -

// πŸ‘‰ 5. create global state
export const [useGlobalState, dispatch] = createState<
  GlobalState,
  GlobalActions
>({
  initialState: {
    ...userSearchInit,
    // ...init1, ...init2, ...
  },
  reducer: pipe({
    partials: [
      userSearchPartial,
      // ...partial1, ...partial2, ...
    ],
  }),
});

Info:

  • pipe is the new utility that can be used to combine partial reducers into a single reducer. It also supports specifying sideEffects and an errorHandler.
    • You can use sideEffects to log telemetry for your app.

Dispatching the actions

Now, we can use the dispatch function to fetch or refetch user search results -

// Now, dispatch those actions!.

dispatch({
  type: 'Invoke_UserSearch',
  args: ['searchParam'],
});

Info: Invoke_UserSearch will run the searchUsers function with the arguments ['searchParam'] and update the phasor with

  1. phase: Phase.run if it was previously at Phase.ready
  2. phase: Phase.rerun if it was previously at Phase.done or Phase.fail

This is useful to detect when error based retries are happening as opposed to initial fetching of data, or fetching next batch of data of for the same set of information.

Using the phasor in components

You can now use the phasor in your components as you need. Here is an example -

function MyComponent() {
  const UserSearch = useGlobalState((state) => state.UserSearch);

  useEffect(() => {
    if (isPhasor.ready(UserSearch) || isPhasor.settled(UserSearch)) {
      dispatch({
        type: 'Invoke_UserSearch',
        args: ['searchParam'],
      });
    }
  }, []);

  if (isPhasor.inRun(UserSearch)) {
    return <div>Loading...</div>;
  }

  if (isPhasor.done(UserSearch)) {
    return <div>{UserSearch.value}</div>;
  }

  if (isPhasor.failed(UserSearch)) {
    return <div>{UserSearch.error}</div>;
  }
}

Info:

  • isPhasor is a new utility that can be used to check the status of a phasor.

FAQs

1. What do I do if I need to use state within the fetcher function?

You can always use useGlobalState hook to get the state and use it within the fetcher function like below -

async function searchUsers(searchTerm: string) {
  const { UserSearch } = useState.getState();
  const skip = isPhasor.done(UserSearch)
    ? state.UserSearchResult.data.length
    : 0;

  // note: assume auth is configured somewhere else
  // by calling ODataEndpoint.configure(...);
  const results = await users()
    .search(searchTerm)
    .skip(skip)
    .fetch()
    .then((res) => res.json());

  return results.value;
}

2. How do I mock the fetcher function in jest?

You can use jest.spyOn to mock the fetcher function -

import * as searchUsersImport from './searchUsers';

jest.spyOn(searchUsersImport, 'searchUsers').mockResolvedValue([]);

3. Why are the action types not enums?

enums are a compile time construct for TypeScript. Although they are available at run time, TypeScript cannot make strong inferences in compile time if we try to mimic the enum's behavior with an object or any other equivalent. So, we have to use a string literal union type instead to make the type property dynamic and easily controlled by consumers of this library.

4. What happens when we dispatch Invoke_ action multiple times synchronously?

Only the first dispatch will have any effect. Because, after that, the phasor would have been in a run state - and when it is in this state, the partial reducer returns undefined when Invoke_ action is dispatched. This is to prevent multiple invocations of the fetcher function while in progress. However, if you want to force a re-run, you can dispatch Invoke_ action after the phasor has settled (after done or fail state is reached).

Phasors

Phasor

a vector that represents a sinusoidally varying quantity

The word Phasor is heavily used in Electrical engineering to represent a time-based signal in a tine-invariant format - meaning to convert "time" into "frequency" and "phase". We are using it here to represent an async function's execution in a time-invariant format - meaning to convert "time" into "status" and "result" or "error".

Note: This idea is similar to Futures, Fetchables, Loadables, Options and other discriminated union constructs of representing a finite state machine as data.

Consider this state machine -

Phasor state machine

Here is what each phase means in terms of async functions and react state -

Phase Async function meaning
Ready Has not been called yet
Run Has been called and is in progress.
Done Has been called and has completed successfully
Fail Has been called and has failed with an error
Rerun Has been called again and is in progress

Async function is not something we can observe in any phase of it's execution. You can only await on it and see the end result or catch the error. Imagine if you could convert that into something you can observe at all times, essentially answering the question "what is my Async function doing now?". That's exactly what we get when we convert this time-based entity to something that can be represented as an object - a Phasor.

Basically you create an object that has information about what is happening with your Async function at all times - is it not called yet? Is it running? Did it resolve? Did it throw? Is it being retried? Etc.

A phasor makes it easier to store that time based information in an object based global state that a reducer can handle. So, a Phasor object looks like one of the below -

// Ready to start
let myPhasor = {
  phase: Phase.ready
}
// Running
let myPhasor = {
  phase: Phase.run
}
// Finished with result
let myPhasor = {
  phase: Phase.finish
  result: 'network data'
}
// Failed with error
let myPhasor = {
  phase: Phase.fail
  error: Error()
}
// Rerunning after success
let myPhasor = {
  phase: Phase.rerun
  result: 'network data'
}
// Rerunning after error
let myPhasor = {
  phase: Phase.rerun
  error: Error()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment