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

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/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.