Skip to main content

Collection

Collections define mutable Lists (Array) or Maps (Values).

This means they can grow and shrink. You can add to Collection(Array) with .push or .unshift, remove from Collection(Array) with .remove, add to Collections(Values) with .assign, and move between collections with .move.

RestEndpoint provides .push, .unshift, .assign, .remove, .move and .getPage/ .paginated() extenders when using Collections

Usage

import { useController } from '@data-client/react';
import { getTodos } from './api/Todo';

export default function NewTodo({ userId }: { userId?: string }) {
  const ctrl = useController();
  const [unshift, setUnshift] = React.useState(false);

  const handlePress = async e => {
    if (e.key === 'Enter') {
      const createTodo = unshift ? getTodos.unshift : getTodos.push;
      ctrl.fetch(createTodo, {
        title: e.currentTarget.value,
        userId,
      });
      e.currentTarget.value = '';
    }
  };

  return (
    <div className="listItem nogap">
      <TextInput size="small" onKeyDown={handlePress} />
      <label>
        <input
          type="checkbox"
          checked={unshift}
          onChange={e => setUnshift(e.currentTarget.checked)}
        />{' '}
        unshift
      </label>
    </div>
  );
}
🔴 Live Preview
Store

Collection with Values

When an API returns keyed objects rather than arrays, combine Collection with Values to enable mutations on the result.

import { Entity, resource, Collection, Values } from '@data-client/rest';

class Stats extends Entity {
product_id = '';
volume = 0;
price = 0;

pk() {
return this.product_id;
}

static key = 'Stats';
}

export const StatsResource = resource({
urlPrefix: 'https://api.exchange.example.com',
path: '/products/:product_id/stats',
schema: Stats,
}).extend({
getList: {
path: '/products/stats',
// Collection wraps Values to enable .push, .assign, etc.
schema: new Collection(new Values(Stats)),
process(value) {
// Transform nested response structure
Object.keys(value).forEach(key => {
value[key] = {
...value[key].stats_24hour,
product_id: key,
};
});
return value;
},
},
});

This allows adding or updating entries with .assign. The body is an object where keys are the collection keys and values are the entity data to merge:

// Local-only update with ctrl.set()
ctrl.set(StatsResource.getList.schema.assign, {}, {
'BTC-USD': { product_id: 'BTC-USD', volume: 1000 },
});

// Network request with ctrl.fetch() - see RestEndpoint.assign
await ctrl.fetch(StatsResource.getList.assign, {
'BTC-USD': { product_id: 'BTC-USD', volume: 1000 },
});

Options

One of argsKey or nestKey is used to compute the Collection's pk.

argsKey(...args): Object

Returns a serializable Object whose members uniquely define this collection based on Endpoint arguments.

import { RestEndpoint, Collection } from '@data-client/rest';

const getTodos = new RestEndpoint({
path: '/todos',
searchParams: {} as { userId?: string },
schema: new Collection([Todo], {
argsKey: (urlParams: { userId?: string }) => ({
...urlParams,
}),
}),
});

nestKey(parent, key): Object

Returns a serializable Object whose members uniquely define this collection based on the parent it is nested inside.

Nested Collection's pk are better defined by what they are nested inside. This allows the nested Collection to share its state with other instances whose key has the same value.

import { Collection, Entity } from '@data-client/rest';

class Todo extends Entity {
id = '';
userId = '';
title = '';
completed = false;

static key = 'Todo';
}

class User extends Entity {
id = '';
name = '';
username = '';
email = '';
todos: Todo[] = [];

static key = 'User';
static schema = {
todos: new Collection([Todo], {
nestKey: (parent, key) => ({
userId: parent.id,
}),
}),
};
}

In this case, user.todos and getTodos() response (from the argsKey example) will always be the same (referentially equal) Array.

nonFilterArgumentKeys?

A convenient alternative to argsKey

nonFilterArgumentKeys defines a test to determine which argument keys are not used for filtering the results. For instance, if your API uses 'orderBy' to choose a sort - this argument would not influence which entities are included in the response.

const getPosts = new RestEndpoint({
path: '/:group/posts',
searchParams: {} as { orderBy?: string; author?: string },
schema: new Collection([Post], {
nonFilterArgumentKeys(key) {
return key === 'orderBy';
},
}),
});

For convenience you can also use a RegExp or list of strings:

const getPosts = new RestEndpoint({
path: '/:group/posts',
searchParams: {} as { orderBy?: string; author?: string },
schema: new Collection([Post], {
nonFilterArgumentKeys: /orderBy/,
}),
});
const getPosts = new RestEndpoint({
path: '/:group/posts',
searchParams: {} as { orderBy?: string; author?: string },
schema: new Collection([Post], {
nonFilterArgumentKeys: ['orderBy'],
}),
});

In this case, author and group are considered 'filter' argument keys, which means they will influence whether a newly created should be added to those lists. On the other hand, orderBy does not need to match when push is called.

import { Entity, Query, Collection, RestEndpoint } from '@data-client/rest';

class Post extends Entity {
  id = '';
  title = '';
  group = '';
  author = '';
}
export const getPosts = new RestEndpoint({
  path: '/:group/posts',
  searchParams: {} as { orderBy?: string; author?: string },
  schema: new Query(
    new Collection([Post], {
      nonFilterArgumentKeys: /orderBy/,
    }),
    (posts, { orderBy } = {}) => {
      if (orderBy) {
        return [...posts].sort((a, b) => a[orderBy].localeCompare(b[orderBy]));
      }
      return posts;
    },
  )
});
🔴 Live Preview
Store

createCollectionFilter?

Sets a default createCollectionFilter for addWith(), push, unshift, and assign.

This is used by these creation schemas to determine which collections to add to.

Default:

createCollectionFilter(...args: Args) {
return (collectionKey: Record<string, string>) =>
Object.entries(collectionKey).every(
([key, value]) =>
this.nonFilterArgumentKeys(key) ||
// strings are canonical form. See pk() above for value transformation
`${args[0][key]}` === value ||
`${args[1]?.[key]}` === value,
);
}

Methods

These creation/removal schemas can be used with Controller.set() for local-only updates without network requests. For network-based mutations, see RestEndpoint's specialized extenders.

push

A creation schema that places new item(s) at the end of this collection.

// Add a new todo to the end of the list (local only, no network request)
ctrl.set(getTodos.schema.push, { userId: '1' }, { id: '999', title: 'New Todo' });

unshift

A creation schema that places new item(s) at the start of this collection.

// Add a new todo to the beginning of the list (local only)
ctrl.set(getTodos.schema.unshift, { userId: '1' }, { id: '999', title: 'New Todo' });

remove

A schema that removes item(s) from a collection by value.

The entity value is normalized to extract its pk, which is then matched against collection members. Items are removed from all collections matching the provided args (filtered by createCollectionFilter).

// Remove from collections matching { userId: '1' } (local only)
ctrl.set(getTodos.schema.remove, { userId: '1' }, { id: '123' });
// Remove from all collections (empty args matches all)
ctrl.set(getTodos.schema.remove, {}, { id: '123' });

For network-based removal that also updates the entity, see RestEndpoint.remove.

move

A schema that moves item(s) between collections. It removes the entity from collections matching its existing state and adds it to collections matching the entity's new state (derived from the last arg).

This works for both Collection(Array) and Collection(Values).

// Move todo from userId '1' collection to userId '2' collection (local only)
ctrl.set(
getTodos.schema.move,
{ id: '10', userId: '2', title: 'Moved todo' },
[{ id: '10' }, { userId: '2' }],
);

The remove filter uses the entity's existing values in the store to determine which collections it currently belongs to. The add filter uses the merged entity values (existing + last arg) to determine where it should be placed.

For network-based moves, see RestEndpoint.move.

assign

A creation schema that assigns its members to a Collection(Values). Only available for Collections wrapping Values.

const getStats = new RestEndpoint({
path: '/products/stats',
schema: new Collection(new Values(Stats)),
});

// Add/update entries in a Values collection (local only)
ctrl.set(getStats.schema.assign, {}, {
'BTC-USD': { product_id: 'BTC-USD', volume: 1000 },
'ETH-USD': { product_id: 'ETH-USD', volume: 500 },
});

addWith(merge, createCollectionFilter): CreationSchema

Constructs a custom creation schema for this collection. This is used by push, unshift, assign and paginate

merge(collection, creation)

This merges the value with the existing collection

createCollectionFilter

This function is used to determine which collections to add to. It uses the Object returned from argsKey or nestKey to determine if that collection should get the newly created values from this schema.

Because arguments may be serializable types like number, we recommend using == comparisons, e.g., '10' == 10

(...args) =>
collectionKey =>
boolean;

Lifecycle Methods

static shouldReorder(existingMeta, incomingMeta, existing, incoming): boolean

static shouldReorder(
existingMeta: { date: number; fetchedAt: number },
incomingMeta: { date: number; fetchedAt: number },
existing: any,
incoming: any,
) {
return incomingMeta.fetchedAt < existingMeta.fetchedAt;
}

true return value will reorder incoming vs in-store entity argument order in merge. With the default merge, this will cause the fields of existing entities to override those of incoming, rather than the other way around.

static merge(existing, incoming): mergedValue

static merge(existing: any, incoming: any) {
return incoming;
}

static mergeWithStore(existingMeta, incomingMeta, existing, incoming): mergedValue

static mergeWithStore(
existingMeta: { date: number; fetchedAt: number },
incomingMeta: { date: number; fetchedAt: number },
existing: any,
incoming: any,
): any;

mergeWithStore() is called during normalization when a processed entity is already found in the store.

pk: (parent?, key?, args?): pk?

pk() calls argsKey or nestKey depending on which are specified, and then serializes the result for the pk string.

pk(value: any, parent: any, key: string, args: readonly any[]) {
const obj = this.argsKey
? this.argsKey(...args)
: this.nestKey(parent, key);
for (const key in obj) {
if (typeof obj[key] !== 'string') obj[key] = `${obj[key]}`;
}
return JSON.stringify(obj);
}