How can I let H5P content types store the state actively?

The core of H5P takes care of storing contents’ state automatically if the resume feature is activated in the H5P integration settings. State is stored in regular intervals and based on some content type events and browser events.

However, there may be a good reason why you don’t want to wait for H5P core to fetch the data from your content type. Maybe you want to allow the user to actively hit a “Save” button. The function that you are looking for is called setUserData in H5P core. We’ll take a closer look later on.

The important thing is that you should be aware that every time you request to store the state a storage operation might be run on the server. Multiply that with the number of users that are using the content type simultaneously … You get it.

H5P has two mechanisms to prevent spamming the server with requests, but those are not fail-safe. The first one is a 250 ms cooldown period on the client, but it’s only meant for H5P core’s own calls that are triggered by certain events. It will not kick in if you call setUserData directly. The second one is a shallow object comparison between the last stored state and the state that is supposed to be stored. This also happens on the client. It will help to prevent unnecessary storage processes if nothing changed. But if you for instance, wanted to store the state of a timer that runs down and saved every 300 ms or something similar … Phew.

How do I know whether I can store the state or not?

To avoid unnecessary requests, you should check whether the H5P integration accepts states to be stored at all. Unfortunately, so far there’s only a way that one should not go. H5P core holds a global H5PIntegration object that is the glue between the server and the client. It is used to transfer a couple of configuration values from the server to the client. One of these properties is saveFreq which could be undefined or false if the server does not accept states, or it could be a positive number representing the regular save interval in seconds. So, you can use something along the lines of

const canStoreState = !Number.isNaN(parseInt(H5PIntegration?.saveFreq));

to determine whether the state can be stored. However, this is not recommended. The H5PIntegration object should not be read directly, because the data structure might change. On the other hand, a proper solution is still not on the horizon. At some point, this will probably be solved by passing a flag to the H5P instance in its constructor function’s 3rd parameter like it’s done for some other values, but that still future talk.

What if my content type is run as subcontent?

Our content type could be running as the main instance (the root instance), or it could be running as a subcontent somewhere down the instance tree. That’s relevant, because H5P core expects to talk to the root instance that the content id is associated with. It does not expect to get the state from some subcontent. If it did, then the next time the user started the content, he or she might be greeted with some blank H5P content – the state that the root instance receives when resuming might not be what it expects. That means, if your content is a root instance, you can store the return value of your own getCurrentState function. Otherwise, you need to make sure to call the getCurrentState function of the root instance and pass its return value. So, what can yo do?

First of all, every H5P instance inherits a function called isRoot that you can use to determine whether your content is the root instance or a subcontent. If it is the root instance, then you can call getCurrentState on your instance or on a reference that you pass down to some other classes. If not, we need to find the root instance. 

The simplest way is to retrieve H5P.instances[0]. The instances array holds references to all H5P root instances inside the context of the H5P object. That will always be just one if the H5P content uses an iframe to render. However, in theory, content types can also render in a div. That means if there are multiple, div rendered content types on a page, using H5P.instances[0] might give you the reference to an H5P instance that is not the root of your content. Given that all compound content types that I know of use an iframe, chances for failure are very slim here though.

If you really wanted to be sure that this doesn’t fail, then you would retrieve the contentId property that H5P kindly sets on your instance and check all the items of H5P.instances for the same content id. One could, potentially, also use the parent property which might be passed with the 3rd constructor’s argument – but that would require all compound content types to set it and not all do. So, you might not be able to get to the root that way.

So what’s that setUserData function now?

The setUserData function is part of a set of functions (together with getUserData and deleteUserData) that H5P core uses to manage data attached to some user, e.g. for storing the state. The JSDoc comments should give you some insight what it is used for. The items that are relevant for the state are set in bold.

/**
 * @static
 * @param {number} contentId Content id.
 * @param {string} dataId Identifier for context, here 'state'.
 * @param {object} data Data to be stored, here for state.
 * @param {object} [extras={}] Additional parameters.
 * @param {string} [extras.subcontentId] Connect data to particular subcontent.
 * @param {boolean} [extras.preloaded=true] If true, will be loaded with content.
 * @param {boolean} [extras.deleteOnChange=false] It true, delete data if content changes.
 * @param {function} [extras.errorCallback] Error callback with error as parameter.
 * @param {boolean} [extras.async=true] If true, server request is asynchronous.
 */
H5P.setUserData(contentId, dataId, data, extras = {})

We can use that function to store the state as H5P expects it:

  • We need to pass the current contentId, so H5P knows what content the state should be linked to.
  • We need to tell H5P core some dataId as an identifier for the purpose of the data that are supposed to be stored. In this case, state is the appropriate value.
  • We need to pass some data, obviously.
  • We could pass some extra options, and extras.deleteOnChange can be useful. If set to true, then when the author changes something in the content, the state will be reset. That behavior seems to be based on the premise that some question might have changed and the answers are not valid anymore – or that the data structure of the state may have changed. H5P core uses that behavior by default. If set to false, changes that the author made will not cause the state to be reset and the user will still keep his/her previous answers – but you may have to ensure that the previous state still makes sense for the changes.

What could a solution look like?

A solution for actively storing state could look like the example below. It should be okay to be used in production, but you will need to adjust it to suit your needs, of course.

/*
 * Assuming this is part of your main instance class! You would
 * need to adjust references to 'this' if you put the storing
 * function inside another class and work with parameters if you
 * put it into a static service class, for instance.
 */

/**
 * @class
 * @param {object} params Parameters passed by the editor.
 * @param {number} contentId Content's id.
 * @param {object} [extras={}] Saved state, metadata, etc.
 */
constructor(params, contentId, extras = {}) {
  this.canStoreState = !Number.isNaN(parseInt(H5PIntegration?.saveFreq));
  this.stateProvider = this.retrieveStateProvider(); 

  /*
   * Here put whatever you need to do when constructing, in particular
   * check 'extras.previousState' for a previous state that you want
   * to re-create.
   */
}

/**
 * Retrieve state provider to get current state from.
 * @returns {H5P.ContentType|null} Instance to get current state from.
 */
retrieveStateProvider() {
  let stateProvider = this.isRoot() ? this : null;
  if (stateProvider) {
    return stateProvider;
  }
  // Find root instance for our subcontent instance
  const rootInstance = H5P.instances
    .find((instance) => instance.contentId === this.contentId);

  // Check root instance for having support for resume
  if (typeof rootInstance?.getCurrentState === 'function') {
    stateProvider = rootInstance;
  }

  return stateProvider;
}

/**
 * Store current state.
 *
 * Could be amended if required, of course, e.g. add an options
 * argument to control the 'deleteOnChange' value, add a callback
 * function to be passed to H5P.setUserData, etc.
 */
storeH5PState() {
  if (!this.canStoreState) {
    return; // Server does not store state.
  }

  if (!this.stateProvider) {
    return; // No state provider available.
  }

  H5P.setUserData(
    this.contentId, // Set automatically by H5P core
    'state',
    this.stateProvider.getCurrentState(),
    { deleteOnChange: true } // Use default behavior of H5P core
  );    
}

/**
 * Answer call from H5P core for current state.
 *
 * @returns {object} Current state.
 */
getCurrentState() {
  return {}; // Add the state as you need it
}