Datx Store Provider
Last modified on Wed 25 Sep 2024

datx

datx.dev - A mobx data store

DatX is an opinionated JS/TS data store. It features support for simple property definition, references to other models, and first-class TypeScript support.

To find out more about DatX, check out the official documentation.

To set up datx store, first, we need to install DatX dependency. To do this, follow the official documentation.

After that, we can create a collection and models.

import { Collection } from '@datx/core';

export class Client extends Collection {
  public static types = [];
}

types variable should be a populated array with the model that your app uses. For the sake of this demonstration, it will be empty, but in the last section of this page, some examples will be shown.

To find out more about Datx, check out the official documentation.

JSON:API Client

If you are working with a JSON:API, datx can handle that too - hello datx-jsonapi. This library provides multiple decorators that you can use to adapt collection and models to work with JSON:API. Read more about JSON:API and datx-jsonapi

Extra

If you want to follow along completely, here is a list of all needed dependencies:

After datx store is initialized, we will create a datx context that will enable us to use DatX in our web application. For the context, we will need a provider - DatxProviderand a hook - useDatx.

DatxProvider

For frontend network layer we will use SWR library created by Vercel.

// DatxProvider.tsx

import { createContext, FC } from 'react';
import { SWRConfig } from 'swr';
import { dequal } from 'dequal/lite';

import { Client } from './Client';

export const DatxContext = createContext<Client>(null);

interface IDatxProviderProps {
  client: Client;
}

export const DatxProvider: FC<IDatxProviderProps> = ({ client, children }) => (
  <DatxContext.Provider value={client}>
    <SWRConfig value={{}}>{children}</SWRConfig>
  </DatxContext.Provider>
);

Let's break things up into sections:

Once the provider is created, wrap your whole application inside the created provider. In React application, you can do this in index.ts file and in Next.js application, you can do this in _app.ts file.

useDatx

As mentioned, useDatx is a hook that will expose datx context in our component. To make it simpler, we will wrap useContext hook in our new useDatx hook.

// useDatx.ts

import { useContext } from 'react';

import { DatxContext } from './context';

export function useDatx() {
  const client = useContext(DatxContext);

  if (!client) {
    throw new Error('useDatx must be used inside DatxProvider');
  }

  return client;
}

This hook will throw an error if useDatx is called without DatxProvider. If you followed the previous step, this will be already implemented in a index or _app file.

Usage

In this section, an example will be shown. Before we start, we'll create a model, i.e. Todo.

import { Attribute, Model } from '@datx/core';

export class Todo extends Model {
  static type = 'todo';

  @Attribute({ isIdentifier: true })
  public id!: string | number;

  @Attribute()
  public title!: string;

  @Attribute()
  public completed!: boolean;

  @Attribute()
  public createdAt!: string;
}

Now we need to modify our collection and add this model to types array.

import { Todo } from 'models';

export class Client extends Collection {
  public static types = [Todo];
}

Once this is set up, and if we assume that all steps defined in this document are done, we can start fetching. For this example, we'll create a component that will fetch todos from the API and render them on the page.

Before we create a component, we will create a fetcher.

export const getTodos = async (datx) => {
  const response = await fetch('/todos');
  const rawTodos = await response.json();
  const todos = rawTodos.map((rawTodo) => datx.add(rawTodo, 'todo'));

  return todos;
};

Now, we can use the fetcher to create a component.

import React, { FC } from 'react';
import useSWR from 'swr';

import { useDatx } from 'store';

export const TodoListSection: FC = (props) => {
  const datx = useDatx();
  const { data: todos, error } = useSWR('/todos', () => getTodos(datx));

  if (error) {
    return (
      <div {...props}>
        <p>Something went wrong.</p>
      </div>
    );
  }

  if (!todos) {
    return <div {...props}>Loading todos...</div>;
  }

  return (
    <section {...props}>
      {todos.length === 0 && <p>No todos.</p>}
      {todos.map((todo) => (
        <div className='flex'>
          <input type='checkbox' defaultChecked={todo.completed} />
          <p>{todo.title}</p>
        </div>
      ))}
    </section>
  );
};

JSON:API usage

Here at Infinum, we like to use JSON:API specification. To make the stack easier to maintain, DatX has an extension just for that, JSON:API, specification - @datx/jsonapi. If you want to know more about that, please read more at the official documentation site. For the purposes of this example, we'll create a model and show a short demonstration how to implement DatX jsonapi it in your application.

To create a JSON:API model you need to decorate existing DatX model using jsonapi decorator method from the package.

import { Attribute, Model } from '@datx/core';
import { jsonapi } from '@datx/jsonapi';

export class Flight extends jsonapi(Model) {
  static type = 'flight';

  @Attribute({ isIdentifier: true })
  public id!: string | number;

  @Attribute()
  public airplaneModel!: string;

  @Attribute()
  public departsAt!: string;

  @Attribute()
  public arrivesAt!: string;

  @Attribute()
  public basePrice!: string;

  @Attribute()
  public currentSeatPrice!: string;

  @Attribute()
  public name!: string;
}

Then, we can use this model to create out store:

import { Collection } from '@datx/core';

import { Flight } from 'models/flight';

export class AppCollection extends jsonapi(Collection) {
  public static types = [Flight];
}

Now, once this is setup we also need to provide out config to DatX. To do this, we need to use config object from @datx/jsonapi.

import { config, CachingStrategy, IRawResponse, ICollectionFetchOpts } from '@datx/jsonapi';

import { apify, deapify } from 'utils/api';

// since we are using swr, swr will handle cache
config.cache = CachingStrategy.NetworkOnly;

// base url of an api service
config.baseUrl = 'http://localhost:3000/api/v1/';

// these fetch options will be included on every request
config.defaultFetchOptions = {
  // JSON:API standard requires to provide following headers. The default headers required by the spec are added by default, but you're able to override this.
  headers: {
    Accept: 'application/vnd.api+json',
    'Content-Type': 'application/vnd.api+json',
  },
  credentials: 'include',
};

// in order to handle attributes in camelCase rather than snake_case, we'll transform all prop names to camelCase
config.transformResponse = (opts: IRawResponse) => {
  return { ...opts, data: deapify(opts.data) };
};

// same as previous step, but in reverse; all prop names from camelCase to snake_case
config.transformRequest = (opts: ICollectionFetchOpts) => {
  return { ...opts, data: apify(opts.data) };
};

Mentioned methods apify and deapify are using lodash methods under the hood. For that you'll need to install lodash (or only the specific lodash methods) as well:

pnpm install -E lodash
import camelCase from 'lodash/camelCase';
import snakeCase from 'lodash/snakeCase';

/**
 * Deep iteration trough an object and transformation
 *
 * @param obj - Object that needs to be Transformed
 * @param transformer - Transformer function
 * @return Transformed object
 */
export function iterator(
  obj: object | undefined,
  transformer: typeof snakeCase | typeof camelCase
): object | undefined {
  if (isArray(obj)) {
    return map(obj, (value) => iterator(value, transformer));
  }
  if (isObject(obj)) {
    const copy = mapValues(obj, (value) => iterator(value, transformer));

    return mapKeys(copy, (_, key) => transformer(key));
  }

  return obj;
}

export function apify(obj?: object) {
  return iterator(obj, snakeCase);
}

export function deapify(obj?: object) {
  return iterator(obj, camelCase);
}

Now, everything is ready for usage in the components:

import React, { FC } from 'react';
import useSWR from 'swr';

import { useDatx } from 'store';
import { Flight } from 'models/flight';

export const FlightDetailsSection: FC = ({ flightId, ...rest }) => {
  const datx = useDatx();

  const { data: flightResponse, error } = useSWR(
    () => (flightId ? `${Flight.type}-${flightId}` : null), // swr key
    () => datx.getOne(Flight, flightId) // swr fetcher
  );

  if (error) {
    // implement better error handling, this is just for demo purposes
    return <div {...rest}>Flight fetch error occurred</div>;
  }

  if (!flightResponse.data) {
    // implement better loading state, this is just for demo purposes
    return <div {...rest}>Loading data...</div>;
  }

  return (
    <div {...rest}>
      {/* Handle flight data */}
      {JSON.stringify(flightResponse.data)}
    </div>
  );
};

As you can see this is not really complicated to set up. But all of this becomes hard to track once you have multiple places with request filters etc. Because swr uses the key attribute to cache data and JSON:API supports multiple different parameters, key handling becomes an difficult task. To bypass that, we created a piece of code with better abstraction that will connect three things - DatX, React and SWR. You can checkout the implementation in our React example repo.