Фронтенд для Web3 dApp: хорошие практики

1. Discriminated Unions для Type Safety

Использование discriminated unions в TypeScript с property kind создает type-safe подход к моделированию различных стейтов, ивентов и команд:

// State modeling with discriminated unions
type TxNorma…


This content originally appeared on DEV Community and was authored by Niko Borisov

1. Discriminated Unions для Type Safety

Использование discriminated unions в TypeScript с property kind создает type-safe подход к моделированию различных стейтов, ивентов и команд:

// State modeling with discriminated unions
type TxNormalStateIdle = { kind: 'idle' };
type TxNormalStateSending = { kind: 'sending' };
type TxNormalStateSuccess = { kind: 'success'; txHash: Hash };
type TxNormalStateError = { kind: 'error'; error: BaseError };

export type TxNormalState =
  | TxNormalStateIdle
  | TxNormalStateSending
  | TxNormalStateSuccess
  | TxNormalStateError;

2. Command-Event-State Pattern

Разделение пользовательских действий (commands), системных событий (events) и состояния приложения (state) позволяет выстроить четкий однонаправленный поток данных:

// Commands - user actions
type Submit = {
  kind: 'submit';
  data: { tx: () => WriteContractParameters };
};

// Events - system responses
type SuccessEvent = {
  kind: 'success';
  data: { hash: Hash };
};

// State - application state
type TxAllowanceStateApproveNeeded = {
  kind: 'approve-needed';
  amount: bigint;
};

3. Finite State Machines для UI

Моделирование функциональности через finite state machines (FSM) делает переходы состояний явными и предотвращает illegal state transitions:

reducer: (state, event) => {
  switch (event.kind) {
    case 'check':
      return {
        ...state,
        kind: 'checking-allowance',
        amount: event.data.amount,
      };
    case 'enough-allowance':
      switch (state.kind) {
        case 'rechecking-allowance':
          return { ...state, kind: 'sending' };
        default:
          return {
            ...state,
            kind: 'has-allowance',
            amount: event.data.amount,
          };
      }
    // ...
  }
}

4. Reactive Programming с RxJS

Использование observables для асинхронных операций обеспечивает лучшую композицию и возможность кэнселить потоки, когда это необходимо (к сожалению, под коробкой у всех Web3 API там все равно дергается промис):

return from(
  this.client.simulateContract(<SimulateContractParameters>cmd.data.tx())
).pipe(
  switchMap(response =>
    from(this.client.writeContract(response.request)).pipe(
      switchMap(txHash => {
        return from(
          this.client.waitForTransactionReceipt({
            hash: txHash,
            confirmations: 1,
          })
        ).pipe(
          map(() =>
            txAllowanceEvent({
              kind: 'success',
              data: { hash: txHash },
            })
          )
        );
      }),
      catchError(err => of(txAllowanceEvent({ kind: 'error', data: err })))
    )
  ),
  startWith(txAllowanceEvent({ kind: 'submitted' })),
  take(2)
);

5. Error as Data, Not Exception

Все ошибки предоставляются в виде обычных данных, а не эксепшенов. Это позволяет делать код максимально явным и предсказуемым, ведь экспешены не просачиваются в слой бизнес логики:

// Error is just another type of state
type TxAllowanceStateError = {
  kind: 'error';
  amount: bigint;
  error: BaseError;
};

// Error is handled through the normal event flow
catchError(err => 
  of(txAllowanceEvent({ 
    kind: 'error', 
    data: err.cause ?? err 
  }))
)

// Inside business logic there is no exception handling, only data handling
tap(result => {
  if (result.kind === 'success') {
    // do something
  } else {
    // do something else, show alert for example
  }
})

6. Plugin-Based Architecture

Разделение сложной функциональности на композиционные, самодостаточные плагины:

@Injectable()
export class TxAllowanceStore extends FeatureStore<
  TxAllowanceCommand,
  TxAllowanceEvent,
  TxAllowanceState,
  TxAllowanceStateIdle
> {
  // Implementation
}

7. Abstract Base Classes для Interface Contracts

Использование абстрактных базовых классов для определения четких контрактов:

@Injectable()
export abstract class WalletBase {
  public abstract requestConnect(): void;
  public abstract getCurrentAddress(): Observable<Hash | null>;
  public abstract getBalance(): Observable<string>;
}

8. Сильная типизация в приложении

Использование дженериков для обеспечения type safety:

export class TxNormalStore extends FeatureStore<
  TxNormalCommand,
  TxNormalEvent,
  TxNormalState,
  TxNormalStateIdle
> {
  // Implementation
}

9. Явное определение initial states

Исходная точка бизнес логики для любого плагина:

initialValue: {
  kind: 'idle',
  amount: 0n,
}

10. Pure Reducers для State Transitions

Использование pure functions для обновления state для поддержания предсказуемости:

reducer: (state, event) => {
  switch (event.kind) {
    case 'reset':
      return { kind: 'idle', amount: 0n };
    case 'success':
      return { ...state, kind: 'success', txHash: event.data.hash };
    // ...
  }
}

Примеры реализации бизнес логики

Пример 1: Token Approval Flow с FSM

Этот пример показывает полный flow для token approval, который является распространенным pattern в DeFi приложениях. Реализация элегантно обрабатывает сложные state transitions:

// From tx-with-allowance.ts
function checkAllowance(
  client: PublicClient & WalletClient,
  data: Check['data']
) {
  return from(
    client.readContract({
      address: data.token,
      abi: erc20Abi,
      functionName: 'allowance',
      args: [data.userAddress, data.spender],
    })
  ).pipe(
    map(actualAllowance => {
      const isEnoughAllowance = actualAllowance >= data.amount;
      if (isEnoughAllowance) {
        return txAllowanceEvent({
          kind: 'enough-allowance',
          data: {
            spender: data.spender,
            token: data.token,
            amount: data.amount,
          },
        });
      } else {
        return txAllowanceEvent({
          kind: 'not-enough-allowance',
          data: {
            spender: data.spender,
            token: data.token,
            amount: data.amount,
          },
        });
      }
    }),
    catchError(() =>
      of(
        txAllowanceEvent({
          kind: 'not-enough-allowance',
          data: {
            spender: data.spender,
            token: data.token,
            amount: data.amount,
          },
        })
      )
    ),
    take(1)
  );
}

Пример 2: Transaction Execution Pipeline

Этот пример демонстрирует пайплайн для выполнения транзакций с последующей обработкой ошибок:

// From tx-normal.ts
submit: cmd => {
  const tx = <SimulateContractParameters>cmd.data.tx();

  console.log('tx: ', tx);

  return from(this.client.simulateContract(tx)).pipe(
    switchMap(response =>
      from(this.client.writeContract(response.request)).pipe(
        switchMap(txHash => {
          return from(
            this.client.waitForTransactionReceipt({
              hash: txHash,
              confirmations: 1,
            })
          ).pipe(
            map(() =>
              txNormalEvent({ kind: 'success', data: { hash: txHash } })
            )
          );
        }),
        catchError(err => {
          return of(txNormalEvent({ kind: 'error', data: err }));
        })
      )
    ),
    catchError(err => {
      return of(
        txNormalEvent({
          kind: 'error',
          data: err.cause ?? err,
        })
      );
    }),
    startWith(txNormalEvent({ kind: 'submitted' })),
    take(2)
  );
}

Пример 3: Token Approval Handler

Этот пример показывает, как обрабатывать процесс token approval с simulation + execution:

// From tx-with-allowance.ts
approve: (cmd: Approve) => {
  return defer(() =>
    from(
      this.client.simulateContract({
        account: cmd.data.userAddress,
        address: cmd.data.token,
        abi: erc20Abi,
        functionName: 'approve',
        args: [cmd.data.spender, MAX_UINT],
        gas: 65000n,
      })
    )
  ).pipe(
    switchMap(response =>
      from(this.client.writeContract(response.request)).pipe(
        switchMap(value => {
          return from(
            this.client.waitForTransactionReceipt({
              hash: value,
              confirmations: 1,
            })
          ).pipe(switchMap(() => checkAllowance(this.client, cmd.data)));
        }),
        catchError(err => {
          return of(
            txAllowanceEvent({
              kind: 'approve-fail',
              data: err.cause ?? err,
            })
          );
        })
      )
    ),
    catchError(err =>
      of(
        txAllowanceEvent({
          kind: 'approve-fail',
          data: err.cause ?? err,
        })
      )
    ),
    startWith(
      txAllowanceEvent({
        kind: 'approve-sent',
        data: {
          spender: cmd.data.spender,
          token: cmd.data.token,
          amount: cmd.data.amount,
        },
      })
    ),
    take(2)
  );
}

Пример 4: State Transitions

// From tx-allowance.ts
reducer: (state, event) => {
  console.log('event: ', event);
  switch (event.kind) {
    case 'reset':
      return { kind: 'idle', amount: 0n };
    case 'success':
      return { ...state, kind: 'success', txHash: event.data.hash };
    case 'error':
      return { ...state, error: event.data, kind: 'error' };
    case 'check':
      return {
        ...state,
        kind: 'checking-allowance',
        amount: event.data.amount,
      };
    case 'enough-allowance':
      switch (state.kind) {
        case 'rechecking-allowance':
          return { ...state, kind: 'sending' };
        default:
          return {
            ...state,
            kind: 'has-allowance',
            amount: event.data.amount,
          };
      }
    // Additional cases omitted for brevity
  }
}

В принципе, все эти подходы я применяю для любых приложений, но в Web3 это особенно актуально, потому что когда кошелек юзера проходит через множество состояний (как в момент привязки, так и в момент транзакции), то без нормального паттерн матчинга получается каша из кучи if else. Подход выше позволяет сделать все так, чтобы компилятор проверял все возможные состояния и переходы между ними, снижая нагрузку на разработчика.


This content originally appeared on DEV Community and was authored by Niko Borisov


Print Share Comment Cite Upload Translate Updates
APA

Niko Borisov | Sciencx (2025-03-15T19:33:33+00:00) Фронтенд для Web3 dApp: хорошие практики. Retrieved from https://www.scien.cx/2025/03/15/%d1%84%d1%80%d0%be%d0%bd%d1%82%d0%b5%d0%bd%d0%b4-%d0%b4%d0%bb%d1%8f-web3-dapp-%d1%85%d0%be%d1%80%d0%be%d1%88%d0%b8%d0%b5-%d0%bf%d1%80%d0%b0%d0%ba%d1%82%d0%b8%d0%ba%d0%b8/

MLA
" » Фронтенд для Web3 dApp: хорошие практики." Niko Borisov | Sciencx - Saturday March 15, 2025, https://www.scien.cx/2025/03/15/%d1%84%d1%80%d0%be%d0%bd%d1%82%d0%b5%d0%bd%d0%b4-%d0%b4%d0%bb%d1%8f-web3-dapp-%d1%85%d0%be%d1%80%d0%be%d1%88%d0%b8%d0%b5-%d0%bf%d1%80%d0%b0%d0%ba%d1%82%d0%b8%d0%ba%d0%b8/
HARVARD
Niko Borisov | Sciencx Saturday March 15, 2025 » Фронтенд для Web3 dApp: хорошие практики., viewed ,<https://www.scien.cx/2025/03/15/%d1%84%d1%80%d0%be%d0%bd%d1%82%d0%b5%d0%bd%d0%b4-%d0%b4%d0%bb%d1%8f-web3-dapp-%d1%85%d0%be%d1%80%d0%be%d1%88%d0%b8%d0%b5-%d0%bf%d1%80%d0%b0%d0%ba%d1%82%d0%b8%d0%ba%d0%b8/>
VANCOUVER
Niko Borisov | Sciencx - » Фронтенд для Web3 dApp: хорошие практики. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2025/03/15/%d1%84%d1%80%d0%be%d0%bd%d1%82%d0%b5%d0%bd%d0%b4-%d0%b4%d0%bb%d1%8f-web3-dapp-%d1%85%d0%be%d1%80%d0%be%d1%88%d0%b8%d0%b5-%d0%bf%d1%80%d0%b0%d0%ba%d1%82%d0%b8%d0%ba%d0%b8/
CHICAGO
" » Фронтенд для Web3 dApp: хорошие практики." Niko Borisov | Sciencx - Accessed . https://www.scien.cx/2025/03/15/%d1%84%d1%80%d0%be%d0%bd%d1%82%d0%b5%d0%bd%d0%b4-%d0%b4%d0%bb%d1%8f-web3-dapp-%d1%85%d0%be%d1%80%d0%be%d1%88%d0%b8%d0%b5-%d0%bf%d1%80%d0%b0%d0%ba%d1%82%d0%b8%d0%ba%d0%b8/
IEEE
" » Фронтенд для Web3 dApp: хорошие практики." Niko Borisov | Sciencx [Online]. Available: https://www.scien.cx/2025/03/15/%d1%84%d1%80%d0%be%d0%bd%d1%82%d0%b5%d0%bd%d0%b4-%d0%b4%d0%bb%d1%8f-web3-dapp-%d1%85%d0%be%d1%80%d0%be%d1%88%d0%b8%d0%b5-%d0%bf%d1%80%d0%b0%d0%ba%d1%82%d0%b8%d0%ba%d0%b8/. [Accessed: ]
rf:citation
» Фронтенд для Web3 dApp: хорошие практики | Niko Borisov | Sciencx | https://www.scien.cx/2025/03/15/%d1%84%d1%80%d0%be%d0%bd%d1%82%d0%b5%d0%bd%d0%b4-%d0%b4%d0%bb%d1%8f-web3-dapp-%d1%85%d0%be%d1%80%d0%be%d1%88%d0%b8%d0%b5-%d0%bf%d1%80%d0%b0%d0%ba%d1%82%d0%b8%d0%ba%d0%b8/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.