Skip to main content

Creating a Custom Adapter

While createHistoryAdapter and createPatchHistoryAdapter cover the most common ways to track history, you may want to create your history entries in a different format. This guide will show you how to create a custom adapter.

buildCreateHistoryAdapter

This function allows you to create a custom adapter by providing a custom configuration object with the callbacks required.

const createCustomHistoryAdapter = buildCreateHistoryAdapter({
onCreate(config) {
// This is called when the adapter is created
},
wrapRecipe(recipe) {
// This is called when a recipe is created
return (state, ...args) => {
// This is called to update the state
recipe(state.present, ...args);

return entry;
};
},
applyEntry(state, incomingEntry, op) {
// This is called to apply a history entry to the state (i.e. undo/redo)
return outgoingEntry;
},
});

onCreate (optional)

This callback is called when the adapter is created. It receives the configuration object if provided.

This is used by createPatchHistoryAdapter to enable patches in Immer.

const onCreate = () => {
enablePatches();
};

getInitialState (optional)

If you want to manage more properties than in the default state, you can provide a function that returns the initial state.

const getCustomInitialState = (data) => {
return {
...getInitialState(data),
extra: "extra",
};
};

wrapRecipe

This callback receives a recipe that operates on the state should return a function that receives the entire history state shape, updates it, and returns a history entry to be added to the past stack.

For example, the createHistoryAdapter does a simple write to state.present and returns the previous state as the history entry.

const wrapRecipe = (recipe) => {
return (state, ...args) => {
// get the non-proxy version, so we can return it as the history entry
const previousState = ensureCurrent(state.present);
// slightly simplified - recipe return behaviour matchers immer
state.present = recipe(state.present, ...args);
return previousState;
};
};

The createPatchHistoryAdapter uses Immer's patches to generate a history entry.

const wrapRecipe = (recipe) => {
return (state, ...args) => {
const [{ present }, redo, undo] = produceWithPatches(state, (draft) => {
// slightly simplified - recipe return behaviour matchers immer
state.present = recipe(draft, ...args);
});
state.present = present;

return { undo, redo };
};
};

applyEntry

This callback receives the current state, an incoming entry, and an operation (either undo or redo). It should return the outgoing entry.

In the context of an undo operation, the incoming entry is the last entry in the past stack, and the outgoing entry is the first entry in the future stack. In the context of a redo operation, the incoming entry is the first entry in the future stack, and the outgoing entry is the last entry in the past stack.

For example, the createHistoryAdapter simply swaps the present state with the incoming entry.

const applyEntry = (state, incomingEntry, op) => {
const outgoingEntry = state.present;
state.present = incomingEntry;
return outgoingEntry;
};

The createPatchHistoryAdapter uses Immer's applyPatches to apply the patches to the state, and returns the incoming entry.

const applyEntry = (state, incomingEntry, op) => {
applyPatches(state.present, incomingEntry[op]);
return incomingEntry;
};

Typescript

Because the returned createCustomHistoryAdapter function is generic, the Typescript needs to be able to act like higher kinded types.

Inspired by HOTScript, this is achieved using an extensible interface.

export interface BaseHistoryStateFn {
data: unknown;
state: BaseHistoryState<this["data"], unknown>;
config: BaseHistoryAdapterConfig;
}

In order to describe your custom state shape and/or configuration, you should create an extended version of this interface, using this["data"] to retrieve the data type.

interface CustomHistoryState<Data> extends BaseHistoryState<Data, CustomEntry> {
extra: string;
}

interface CustomConfig extends BaseHistoryAdapterConfig {
extraConfig?: string;
}

interface CustomHistoryStateFn extends BaseHistoryStateFn {
state: CustomHistoryState<this["data"]>;
config: CustomConfig;
}

You can then provide this interface to buildCreateHistoryAdapter, and all types should infer correctly.

const createCustomHistoryAdapter =
buildCreateHistoryAdapter<CustomHistoryStateFn>({
onCreate(config) {
console.log(config?.extraConfig); // works
},
// is required if the default state is not enough
getInitialState(data) {
return {
...getInitialState(data),
extra: "extra",
};
},
wrapRecipe(recipe) {
// This is called when a recipe is created
return (state, ...args) => {
// State is a Draft<CustomHistoryState<Data>>

return entry; // required to be a CustomEntry
};
},
applyEntry(state, incomingEntry, op) {
// State is a Draft<CustomHistoryState<unknown>>
// Incoming entry is a CustomEntry

return outgoingEntry; // required to be a CustomEntry
},
});

const exampleAdapter = createCustomHistoryAdapter<CounterState>({
extraConfig: "foo",
});
// "foo" logged to console

const initialState = exampleAdapter.getInitialState({ count: 0 });
// ^? CustomHistoryState<CounterState>
Constraining the Data Type

If your custom adapter is only meant to work with a specific data type, you can constrain the Data type in the CustomHistoryStateFn interface.

interface CustomHistoryStateFn extends BaseHistoryStateFn {
dataConstraint: Record<string, unknown>;
state: CustomHistoryState<Data>;
config: CustomConfig;
}

This will be used to constrain the Data type in the createCustomHistoryAdapter function.

const createCustomHistoryAdapter =
// ^? <Data extends Record<string, unknown>>(config: CustomHistoryAdapterConfig<Data>) => HistoryAdapter<Data, CustomHistoryState<Data>>
buildCreateHistoryAdapter<CustomHistoryStateFn>({
// ...
});

// now errors
const exampleAdapter = createCustomHistoryAdapter<number>();

Redux methods

If you want to include the Redux methods in your custom adapter, this is possible with the withRedux enhancer:

import { withRedux } from "history-adapter/redux";

const createCustomHistoryAdapter = withRedux(
buildCreateHistoryAdapter<CustomHistoryStateFn>({
// ...
}),
);

If you want the raw methods separately, a getReduxMethods function is also available.

import { getReduxMethods } from "history-adapter/redux";

const reduxMethods = getReduxMethods(counterAdapter);