Using a session

commercetools Frontend provides a session mechanism for its extensions. Action extensions can read and write the session, while data source and dynamic page handler extensions can only read the session.

You can write the session by setting the sessionData property on the response returned from the action extensions. Then, you can read the session from the sessionData property on the extension's incoming request object.

The session mechanism is meant for server functions. Frontend components can't access the session directly. If you only need to conserve data for the frontend, use cookies, local storage, or a similar mechanism.

Write the session

The Action extensions receives the Request and Response objects as function argument. The mechanics to write the session works as follows:

  1. The action code ensures to have properly initialized sessionData.
  2. During the execution of the action, the session data is updated (in this case, the value 42 is stored).
  3. The extension returns sessionData as part of the Response.

You can store up to 4 KB of arbitrary data in the session. Any write operation that exceeds this limit will fail with a warning in the browser console. To avoid failures, store only necessary information in the session. For example, if you want to persist users' cart information in the session, instead of storing the complete cart object in the session, write cartId to the session and fetch the cart information using cartId from the API hub.

The example below shows an action saveCartIdInSession saving the cartId to the session object.

export default {
actions: {
saveCartIdInSession: async (
request: Request,
actionContext: ActionContext
): Response => {
const sessionData = {
cartId: '42',
...(request.sessionData || {}),
};
return {
body: actualActionResult,
statusCode: 200,
sessionData,
} as Response;
},
},
};

If you return sessionData from an action, you need to maintain all session information, even that which has not been updated. If you only return the sessionData with the keys added by the action, the existing session data will be lost because commercetools Frontend doesn't perform merge on the session but only stores the returned sessionData.

Read the session

Any extension can read the sessionData since it's part of the Request object. An Action extension receives the Request directly as its input. Other extensions (data source and dynamic page handler) receive the Request as part of their corresponding context object. Direct session access in the frontend code is prohibited as the session might contain sensitive data, such as access tokens. Thus, the session JSON Web Token (JWT) is encrypted in the production environment.

To use parts of the session in a Frontend component, you can expose these parts selectively through a data source or action. For example, you can use the following data source extension to read the tracked cartId. The DataSourceContext carries the Request, which in turn carries sessionData (that might be empty if no session was written before).

export default {
'data-sources': {
'example/get-cart-id': (
configs: DataSourceConfiguration,
context: DataSourceContext
): DataSourceResult => {
console.log('Session data', context.request.sessionData?.cartId);
return {
dataSourcePayload: {
cartId: context.request.sessionData?.cartId,
},
};
},
},
};

A Frontend component can use the exposed part of the sessionData after the data source is registered in the Studio, for example:

import React from 'react';
type ShowCartIdTasticProps = {
data: {
cartId: string;
};
};
const ShowCartIdTastic: React.FC<ShowCartIdTasticProps> = ({ data }) => {
return !data?.cartId?.dataSource ? (
<div>No cart Id available. Please continue to use our page!</div>
) : (
<div>
The active cart Id is <strong>{data?.cartId?.dataSource}</strong>
</div>
);
};
export default ShowCartIdTastic;
{
"tasticType": "example/show-cart-id",
"name": "Show active cart Id",
"icon": "star",
"description": "A frontend component showing the active cart Id",
"schema": [
{
"name": "Data source",
"fields": [
{
"label": "Active cart Id",
"field": "cartId",
"type": "dataSource",
"dataSourceType": "example/get-cart-id"
}
]
}
]
}

Caveat about the session

When multiple actions write the session, the last one to finish executing wins. This might lead to unexpected behavior in non-deterministic implementations.

Consider a scenario where you give your customers 50 reward points each time they add an item to the cart and 20 when they add it to the wishlist. The application stores the customer's reward points in the sessionData and batches the updates for reward points to increase efficiency.

The updateRewardPoints function below initiates all the updates in the order the customer interacted with the application.

import React from 'react';
import { fetchApiHub } from '../../../lib/fetch-api-hub';
const SimpleButtonTastic = ({ data }) => {
function updateRewardPoints() {
fetchApiHub('/action/examples/addFiftyRewardsPoints'); // item added to the cart
fetchApiHub('/action/examples/addTwentyRewardsPoints'); // item added to wishlist
fetchApiHub('/action/examples/deductTwentyRewardPoints'); // item removed from wishlist
fetchApiHub('/action/examples/deductFiftyRewardsPoints'); // item removed from the cart
}
return <button onClick={updateRewardPoints}>{data.label}</button>;
};
export default SimpleButtonTastic;

In the above example, the customer should end up with the same reward points as they started with. However, updates are network calls and each request can take a different time to start, execute, and return. Therefore, the final state of the session becomes non-deterministic. The diagram below shows a scenario where deductFiftyRewardsPoints takes the longest to execute.

Action execution with race condition

To avoid this pitfall, you need to write deterministic code. You can re-implement the above example using async/await to happen in a sequential and deterministic manner.

async function updateRewardPoints() {
await fetchApiHub('/action/examples/addFiftyRewardsPoints'); // #1
await fetchApiHub('/action/examples/deductTwentyRewardPoints'); // #2
await fetchApiHub('/action/examples/addTwentyRewardsPoints'); // #3
await fetchApiHub('/action/examples/deductFiftyRewardsPoints'); // #4
}

Action execution without race conditions

The above solution is however limited to action calls in a single function, component, or file. To handle scenarios where various action calls are triggered from different files or components, you can implement a queue that tracks and executes network calls in the order in which they arrive. To implement the queuing mechanism, use the below code in a new packages/frontend/helpers/Queue.ts file in your project.

type QueueItem = {
promise: () => Promise<any>;
resolve: (value: any) => void;
reject: (reason?: any) => void;
};
class Queue {
#queue: QueueItem[] = [];
#promisePending = false;
#stopped = false;
add(promise: () => Promise<any>): Promise<any> {
return new Promise((resolve, reject) => {
this.#queue.push({
promise,
resolve,
reject,
});
this.#handle();
});
}
stop() {
this.#stopped = true;
}
restart() {
this.#stopped = false;
this.#handle();
}
#handle(): void {
if (this.#promisePending || this.#stopped) {
return;
}
const item = this.#queue.shift();
if (!item) {
return;
}
try {
this.#promisePending = true;
item
.promise()
.then((value) => this.#resolve(() => item.resolve(value)))
.catch((err) => this.#resolve(() => item.reject(err)));
} catch (err) {
this.#resolve(() => item.reject(err));
}
}
#resolve(callback: () => void): void {
this.#promisePending = false;
callback();
this.#handle();
}
}
const actionQueue = new Queue();
export { actionQueue };

The packages/frontend/helpers/Queue.ts file exports actionQueue, a Queue instance, that serves as a single queue for all action calls. Instead of calling the action instantly, you can import actionQueue to any component and use the actionQueue.add method to queue the fetchApiHub action call, as shown below.

import { actionQueue } from '<relative-path-to-Queue.ts>';
export const checkout = async () => {
const res = await actionQueue.add(() => {
return fetchApiHub('/action/cart/checkout', {
method: 'POST',
});
});
mutate('/action/cart/getCart', res);
};