Skip to content

Пример 201: Зависимый от нескольких синалов ресурс

Структура директорий выбрана условно для понимания идеи.

Сервис secondary - его внутренний ресурс подписан на выбор персоны из списка который пришел в ответе на запрос сервиса firstly (его внутренний ресурс триггерится собственным счетчиком, который меняет сам пользователь по кнопке Refresh).

tsx
import baseClasses from '~/ui.common.module.scss'
import btnClasses from '~/ui.button.module.scss'
import clsx from 'clsx'
import { useUserInfoService, observer, useSecondaryService } from './store'

const BASE_API_URL = import.meta.env.VITE_BASE_API_URL

export const Example201 = observer(() => {
  const userInfo = useUserInfoService()
  const secondaryService = useSecondaryService()

  return (
    <div
      className={clsx(
        baseClasses.unit,
        baseClasses['unit--wide'],
        baseClasses.stack2)
      }
    >
      <div
        className={baseClasses.absoluteUnitLabel}
        title='Chain of Resources & observer'
      >
        Chain of Resources & observer
      </div>
      <code>{BASE_API_URL}</code>
      <div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
        <button onClick={() => userInfo.inc()} className={clsx(btnClasses.btn, btnClasses.neonBtn, btnClasses['neonBtn--primary'], btnClasses['neonBtn--outlined'])}>Refresh account data</button>
        {
          userInfo.personList.value.map((p) => (
            <button
              key={p.id}
              onClick={() => userInfo.setActivePersonId(p.id)}
              className={
                clsx(
                  btnClasses.btn,
                  btnClasses.neonBtn,
                  btnClasses['neonBtn--secondary'],
                  {
                    [btnClasses['neonBtn--contained']]: !!userInfo.activePersonId.value && p.id === userInfo.activePersonId.value,
                    [btnClasses['neonBtn--outlined']]: p.id !== userInfo.activePersonId.value,
                  },
                )
              }
            >{p.name}</button>
          ))
        }
      </div>
      <div className={baseClasses.unitInternalWrapper}>
        <div className={baseClasses.stack2}>
          <div>{userInfo.apiState.loading ? '🟡 loading...' : !!userInfo.apiState.data ? '🟢 ok' : !!userInfo.apiState.error ? `🔴 err | ${userInfo.apiState.error?.message || 'No error msg'}` : '⚪'}</div>
          <pre className={baseClasses.preNormalizedMin}>{JSON.stringify({ data: userInfo.apiState.data }, null, 2)}</pre>
        </div>
        <div className={baseClasses.stack2}>
          <div>{secondaryService.apiState.loading ? '🟡 loading...' : !!secondaryService.apiState.data ? '🟢 ok' : !!secondaryService.apiState.error ? `🔴 err | ${secondaryService.apiState.error?.message || 'No error msg'}` : '⚪'}</div>
          <pre className={baseClasses.preNormalizedMin}>{JSON.stringify({ data: secondaryService.apiState.data }, null, 2)}</pre>
        </div>
      </div>
    </div>
  )
})

service.firstly.ts - подписан на счетчик.

ts
import { AbstractService } from '@pravosleva/reactive-engine'

const BASE_API_URL = import.meta.env.VITE_BASE_API_URL

type TPerson = {
  id: string;
  name: string;
}

export class UserInfoService extends AbstractService {
  // -- NOTE: DERTY_MISTAKE 2/3 ✅ Correct way (variant 2)
  // constructor(engine: ReactiveEngine) {
  //   super(engine);
  //   this.engine.effect(() => {
  //     // Подписываемся на изменение counter
  //     const _ = this.counter.value;
  //     // Синхронно сбрасываем выбранного пользователя при каждом изменении счетчика
  //     this.engine.untrack(() => this.resetActivePersonId());
  //   });
  // }
  // --

  public counter = this.engine.signal<number>(0, 'example-201:UserInfoService:signal:counter');
  public doubledCounter = this.engine.computed<number>(() => this.counter.value * 2, 'example-201:UserInfoService:computed:counter');

  public activePersonId = this.engine.signal<string | null>(null)

  public setActivePersonId = (val: string) => this.activePersonId.value = val
  private resetActivePersonId = () => this.activePersonId.value = null

  public apiState = this.engine.resource<{ items: TPerson[] }, number>(
    async (source, abortSignal) => {
      // -- NOTE: DERTY_MISTAKE 1/3 ⛔ Dont do that here!
      // this.resetActivePersonId()
      // --

      const res = await fetch(
        [
          `${BASE_API_URL}/profile/search`,
          '?',
          [
            `counter=${source}`,
            '_responseDelay=3000',
            `_addData=${encodeURIComponent(JSON.stringify({
              items: [
                {
                  id: 'person-id-1',
                  name: 'John Doe'
                },
                {
                  id: 'person-id-2',
                  name: 'Jane Doe'
                }
              ]
            } as { items: TPerson[] }))}`
          ].join('&')
        ].join(''),
        { signal: abortSignal }
      );
      return res.json();
    },
    this.counter
  )

  public inc() {
    this.resetActivePersonId() // NOTE: DERTY_MISTAKE 2/3 ✅ Correct way (variant 1)
    this.counter.value += 1
  }

  public personList = this.engine.computed(
    () => !this.apiState.loading && Array.isArray(this.apiState.data?.items) && this.apiState.data?.items?.length > 0
      ? this.apiState.data.items
      : []
  )
}

service.secondary.ts - подписан на успешный результат первого сервиса и выбор пользователя из списка в ответе от первого сервиса.

ts
import { AbstractService } from '@pravosleva/reactive-engine'
import { UserInfoService } from './service.firstly'

const BASE_API_URL = import.meta.env.VITE_BASE_API_URL

type TResData = {
  for_person_id: string;
}

export class SecondaryService extends AbstractService {
  private userInfoService = this.engine.inject(UserInfoService)

  private isUserDataReceived = this.engine.computed<boolean>(
    () => !this.userInfoService.apiState.loading && !!this.userInfoService.apiState.data,
    'example-201:computed:isUserDataReceived'
  )

  private apiDeps = this.engine.computed<[boolean, string | null]>(() => [
    this.isUserDataReceived.value,
    this.userInfoService.activePersonId.value,
  ])

  public apiState = this.engine.resource<TResData, [boolean, string | null]>(
    async (deps, abortSignal) => {
      const [isDataReceived, personId] = deps;

      if (!isDataReceived) throw new Error('Waiting for user data response...');
      if (!personId) throw new Error('Person id wasnt selected...');

      const queryParams = new URLSearchParams({
        personId: String(personId),
        _responseDelay: '2000',
        _addData: JSON.stringify({ for_person_id: personId } as TResData)
      });

      const res = await fetch(
        `${BASE_API_URL}/profile/accessPolicies?${queryParams}`,
        { signal: abortSignal }
      );
      if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
      return res.json();
    },
    this.apiDeps,
    {
      name: 'example-201:resource:accessPolicies',
      resetDataOnSourceChange: true,
      responseValidate: (res) => {
        // Пример: Бэкенд ответил 200, но ожидаемое поле отсутствует, что для нас критично
        if (!res || typeof res.for_person_id !== 'string') {
          return 'No access policies found for this user'; // Запишется в state.error
        }
        return true; // Всё отлично, запишется в state.data
      }
    }
  )
}

store.ts

ts
import { UserInfoService } from './service.firstly'
import { SecondaryService } from './service.secondary'
import { ReactiveEngine, createObserver } from '@pravosleva/reactive-engine'

const engine = new ReactiveEngine()

export const useUserInfoService = () => engine.inject(UserInfoService)
export const useSecondaryService = () => engine.inject(SecondaryService)

const observer = createObserver(engine)

export { engine, observer }