Фронтенд для Web3 dApp: хорошие практики
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(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 { // Implementation } 7. Abstract Base Classes для Interface Contracts Использование абстрактных базовых классов для определения четких контрактов: @Injectable() export abstract class WalletBase { public abstract requestConnect(): void; public abstract getCurrentAddress(): Observable; public abstract getBalance(): Observable; } 8. Сильная типизация в приложении Использование дженериков для обеспечения type safety: export class TxNormalStore extends FeatureStore { // 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

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. Подход выше позволяет сделать все так, чтобы компилятор проверял все возможные состояния и переходы между ними, снижая нагрузку на разработчика.