Skip to main content

Relational data

Reactive Data Client handles one-to-one, many-to-one and many-to-many relationships on entities using Entity.schema

Nesting

Nested members are hoisted during normalization when Entity.schema is defined. They are then rejoined during denormalization

Diagram
 
Fixtures
GET /posts
[{"id":"1","title":"My first post!","author":{"id":"123","name":"Paul"},"comments":[{"id":"249","content":"Nice post!","commenter":{"id":"245","name":"Jane"}},{"id":"250","content":"Thanks!","commenter":{"id":"123","name":"Paul"}}]},{"id":"2","title":"This other post","author":{"id":"123","name":"Paul"},"comments":[{"id":"251","content":"Your other post was nicer","commenter":{"id":"245","name":"Jane"}},{"id":"252","content":"I am a spammer!","commenter":{"id":"246","name":"Spambot5000"}}]}]
resources/Post
import { Collection, Entity } from '@data-client/rest';

export class User extends Entity {
  id = '';
  name = '';
}

export class Comment extends Entity {
  id = '';
  content = '';
  commenter = User.fromJS();

  static schema = {
    commenter: User,
  };
}

export class Post extends Entity {
  id = '';
  title = '';
  author = User.fromJS();
  comments: Comment[] = [];

  static schema = {
    author: User,
    comments: new Collection([Comment], {
      nestKey: (parent, key) => ({
        postId: parent.id,
      }),
    }),
  };
}

export const PostResource = resource({
  path: '/posts/:id',
  schema: Post,
});
PostPage
🔴 Live Preview
Store

Client side joins

Nesting data when your endpoint doesn't.

Even if the network responses don't nest data, we can perform client-side joins by specifying the relationship in Entity.schema

resources/User
resources/Todo
import { User } from './User';

export class Todo extends Entity {
  id = 0;
  userId = 0;
  user? = User.fromJS();
  title = '';
  completed = false;
  static schema = {
    user: User,
  };
  static process(todo) {
    return { ...todo, user: todo.userId };
  }
}
export const TodoResource = resource({
  urlPrefix: 'https://jsonplaceholder.typicode.com',
  path: '/todos/:id',
  schema: Todo,
});
TodoJoined
🔴 Live Preview
Store

Key-based joins

For more complex scenarios where related entities are fetched separately, use Entity.process() to create a reference key that links to another Entity. This is useful when:

  • Related data comes from different API endpoints
  • You want to avoid over-fetching nested data
  • The relationship is optional or varies by context
import { Entity, resource } from '@data-client/rest';

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

pk() {
return this.product_id;
}

static key = 'Stats';
}

class Currency extends Entity {
id = '';
name = '';
// Default value allows Currency to exist without Stats loaded
stats = Stats.fromJS();

pk() {
return this.id;
}

static key = 'Currency';

// Create a reference key that links to Stats entity
static process(input: any, parent: any, key: string, args: any[]) {
// The stats field becomes a reference to Stats with pk `${id}-USD`
return { ...input, stats: `${input.id}-USD` };
}

static schema = {
// Stats will be looked up by the key from process()
stats: Stats,
};
}

When both CurrencyResource.getList and StatsResource.getList are fetched, the stats field will automatically resolve to the matching Stats entity.

Crypto price example

Here we want to sort Currencies by their trade volume. However, trade volume is only available in the Stats Entity. Even though CurrencyResource.getList fetch does not include Stats in the response, we can additionally call StatsResource.getList, while adding it to our Currency's Entity.schema - enabling Stats inclusion in our Currency Entity, which enables sorting with:

entries.sort((a, b) => {
return b?.stats?.volume_usd - a?.stats?.volume_usd;
});

More Demos

Reverse lookups

Nesting data when your endpoint doesn't (part 2).

Even though a response may only nest in one direction, Reactive Data Client can handle reverse relationships by overriding Entity.process. Additionally, Entity.merge may need overriding to ensure deep merging of those expected fields.

This allows you to traverse the relationship after processing only one fetch request, rather than having to fetch each time you want access to a different view.

Fixtures
GET /posts
[{"id":"1","title":"My first post!","author":{"id":"123","name":"Paul"},"comments":[{"id":"249","content":"Nice post!","commenter":{"id":"245","name":"Jane"}},{"id":"250","content":"Thanks!","commenter":{"id":"123","name":"Paul"}}]},{"id":"2","title":"This other post","author":{"id":"123","name":"Paul"},"comments":[{"id":"251","content":"Your other post was nicer","commenter":{"id":"245","name":"Jane"}},{"id":"252","content":"I am a spammer!","commenter":{"id":"246","name":"Spambot5000"}}]}]
resources/Post
import { Collection, Entity } from '@data-client/rest';

export class User extends Entity {
  id = '';
  name = '';
  posts: Post[] = [];
  comments: Comment[] = [];

  static merge(existing, incoming) {
    return {
      ...existing,
      ...incoming,
      posts: [...(existing.posts || []), ...(incoming.posts || [])],
      comments: [
        ...(existing.comments || []),
        ...(incoming.comments || []),
      ],
    };
  }

  static process(value, parent, key) {
    switch (key) {
      case 'author':
        return { ...value, posts: [parent.id] };
      case 'commenter':
        return { ...value, comments: [parent.id] };
      default:
        return { ...value };
    }
  }
}

export class Comment extends Entity {
  id = '';
  content = '';
  commenter = User.fromJS();
  post = Post.fromJS();

  static schema: Record<string, Schema> = {
    commenter: User,
  };
  static process(value, parent, key) {
    return { ...value, post: parent.id };
  }
}

export class Post extends Entity {
  id = '';
  title = '';
  author = User.fromJS();
  comments: Comment[] = [];

  static schema = {
    author: User,
    comments: [Comment],
  };
}

// with cirucular dependencies we must set schema after they are all defined
User.schema = {
  posts: [Post],
  comments: [Comment],
};
Comment.schema = {
  ...Comment.schema,
  post: Post,
};

export const PostResource = resource({
  path: '/posts/:id',
  schema: Post,
  dataExpiryLength: Infinity,
});
export const UserResource = resource({
  path: '/users/:id',
  schema: User,
});
UserPage
PostPage
Navigation
🔴 Live Preview
Store

Circular dependencies

Because circular imports and circular class definitions are not allowed, sometimes it will be necessary to define the schema after the Entities definition.

resources/Post
import { Collection, Entity } from '@data-client/rest';
import { User } from './User';

export class Post extends Entity {
id = '';
title = '';
author = User.fromJS();

static schema = {
author: User,
};
}

// both User and Post are now defined, so it's okay to refer to both of them
User.schema = {
// ensure we keep the 'createdAt' member
...User.schema,
posts: [Post],
};
resources/User
import { Collection, Entity } from '@data-client/rest';
import type { Post } from './Post';
// we can only import the type else we break javascript imports
// thus we change the schema of UserResource above

export class User extends Entity {
id = '';
name = '';
posts: Post[] = [];
createdAt = Temporal.Instant.fromEpochMilliseconds(0);

static schema: Record<string, Schema | Date> = {
createdAt: Temporal.Instant.from,
};
}