effector

Cover image for Using GQty with effector
Sergey Sova
Sergey Sova

Posted on • Updated on

Using GQty with effector

GQTY suggests using integration with React in the form of useQuery, useMutation hooks, and so on.

But when using a state manager, we face the problem of where to store data and a natural desire to move everything about the data and their loading to the state manager, but this creates a second problem - we have to manually transfer data from gqty hooks to the state manager.

Since our projects use effector as a state manager, we will consider the integration with it. First, you need to configure your local gqty instance. Please follow the original instructions at https://gqty.dev/docs/getting-started.

The differences will be in the Configuring Codegen section, the react property should be switched to false, this will not load your bundle with unused hooks, yes, I propose to completely abandon all gqty hooks. After that, you need to delete the generated files, including index.ts

Integration with effector comes down to using a standalone client inside the effects, documentation, and example at https://gqty.dev/docs/client/fetching-data and in the Core Client section of the gqty documentation. The effects already have load markers and load end events, both successful and error events.

Using with effector

Let's start with an example code to retrieve data (query):

import { query, resolved } from '../../api';

const readUserFx = createEffect((userId: string) => resolved(() => {
  const user = query.readUser({ userId })
  if (!user) return null;
  return {
    id: user.id!,
    username: user.username!,
    avatarUrl: user.avatarUrl!,
    tags: user.tags!.map(tag => tag),
    posts: user.posts!.map(post => ({
      id: post.id!,
      title: post.title!,
      content: post.content!,
    })),
  }
}))
Enter fullscreen mode Exit fullscreen mode

Now we can figure out what's going on here and why.

query.readUser({ userId }) doesn't send a query to the server the first time, it only returns a Proxy object so that we can gather the list of fields we need to make a valid query.

In the return expression, we list the fields that we want to get from the query; this is how we describe fields when writing a regular graphQL query.

Exclamation marks in expressions like user.username! are needed to prove to the typescript that the value in the field is certain, otherwise, it will be a string | undefined, which is not the case. https://github.com/gqty-dev/gqty/issues/261

resolved() is a magic function that helps gqty gather the fields the user needs to execute the query. The first time, before executing a query, resolved sets a Proxy instance in the query variable, which collects all the fields accessed by the developer inside the resolved(callback). After the callback is executed, resolved sends the request to the server and returns Promise to the developer. When the server returns the response, resolved substitutes it in the query variable and calls the callback again, already with real data, and then resolves the promise. Note that this is a rough description of the process necessary to explain what's going on.

Any nested data, you also need to select, as well as arrays, even if they are simple, otherwise, you will fall into the data Proxy-objects, which, to put it mildly, are not very pleasant to work with.

But it doesn't look like a convenient solution! Yes, and there are a few ways to simplify life:

Step 1: Create type-caster functions

import { query, resolved, User, Post } from '../../api';

function getPost(post: Post) {
  return {
      id: post.id!,
      title: post.title!,
      content: post.content!,
    }
}

function getUser(user: User) {
  return {
    id: user.id!,
    username: user.username!,
    avatarUrl: user.avatarUrl!,
    tags: user.tags!.map(tag => tag),
    posts: user.posts!.map(getPost),
  }
}

const readUserFx = createEffect((userId: string) => resolved(() => {
  const user = query.readUser({ userId })
  if (!user) return null;
  return getUser(user)
}))
Enter fullscreen mode Exit fullscreen mode

Here it's simple, just put the repeated object getters into functions and reuse them, it's better to put such getters next to the API definition.

Step 2. Use helper functions from gqty

https://gqty.dev/docs/client/helper-functions

import { selectFields } from 'gqty'
import { query, resolved, User } from '../../api'

function getUser(user: User) {
  return selectFields(user, [
    'id',
    'username',
    'avatarUrl',
    'tags',
    'posts.id',
    'posts.title',
    'posts.content',
  ])
}

const readUserFx = createEffect((userId: string) =>
  resolved(() => {
    const user = query.readUser({userId})
    if (!user) return null
    return getUser(user)
  })
)
Enter fullscreen mode Exit fullscreen mode

It is important to read the documentation and carefully check the operation of gqty methods under different conditions.

Step 3. Put all the effects in a separate API layer.

// api.layer.ts
import { selectFields } from 'gqty'
import { query, resolved, User } from './index'

export function getUser(user: User) {
  return selectFields(user, [
    'id',
    'username',
    'avatarUrl',
    'tags',
    'posts.id',
    'posts.title',
    'posts.content',
  ])
}

export const readUserFx = createEffect((userId: string) =>
  resolved(() => {
    const user = query.readUser({userId})
    if (!user) return null
    return getUser(user)
  })
)

// pages/users/model.ts
import { attach } from 'effector'
import * as api from '../../api/api.layer'

const readUserFx = attach({ effect: api.readUserFx })
Enter fullscreen mode Exit fullscreen mode

Now all models can reuse graphQL queries in the same way, without even thinking about how exactly the query is run and what fields get under the hood. But if they need to query additional fields or perform the query differently, they can easily build their query by reusing getUser-like getters.

Why we need attach

In the example, I used the attach method instead of using api.readUserFx directly, for one very important reason:

// pages/users/model.ts
import * as api from '../../api/api.layer'

sample({
  clock: api.readUserFx.done,
  target: showNotification,
})
Enter fullscreen mode Exit fullscreen mode

If we write code without attach, subscribing directly to any effect events, these events will be triggered every time any other model triggers that effect. And since in an application different models can subscribe to the same effect, all the scripts in which the effect is involved will be triggered, without regard to whether the page is open now or not, or whether a certain script triggered the effect or not.

// pages/users/model.ts
import * as api from '../../api/api.layer'

const readUserFx = attach({ effect: api.readUserFx })

sample({
  clock: readUserFx.done,
  target: showNotification,
})
Enter fullscreen mode Exit fullscreen mode

Using attach we create a local copy of the original effect. If each model creates a local effect, and only subscribes and runs its local copy, there won't be any problems with overlapping different scripts.

But keep in mind that running the local effect still triggers events and triggers the parent effect, and if someone subscribes to the global api.readUserFx, they will get all the reactions from all the models, this is useful when building an error handling system.

Discussion (0)