Strongly-typed service-based isomorphic architecture on top of redux-saga
Currently compatible only with typescript codebase with following options enabled
{
"compilerOptions": {
"experimentalDecorators": true
}
}
- sagun
- Keep business logic decoupled from components
- Split your business logic into small services
- Reduce redux boilerplate, library provides the only reducer you need, and all actions are auto-generated
- SSR compatible without logic duplicating
- Dependency injection
- Fully written in typescript
peer dependencies:
npm i --save react react-dom redux react-redux redux-saga immutable
lib install
npm i --save @iiiristram/sagun
recommended to install
npm i --save typed-redux-saga
- provide strongly-typed effects for redux-saga
// bootstrap.tsx
import { applyMiddleware, createStore, combineReducers } from 'redux';
import { Provider } from 'react-redux';
import createSagaMiddleware from 'redux-saga';
import React from 'react';
import ReactDOM from 'react-dom';
import {
ComponentLifecycleService,
OperationService,
asyncOperationsReducer,
Root,
useOperation,
} from '@iiiristram/sagun';
import { call } from 'typed-redux-saga';
import App from './your-app-path.js';
const sagaMiddleware = createSagaMiddleware();
const store = applyMiddleware(sagaMiddleware)(createStore)(
combineReducers({
asyncOperations: asyncOperationsReducer,
})
);
// set up destination for storage
useOperation.setPath(state => state.asyncOperations);
// two basic services which provide library workflow
const operationService = new OperationService();
const componentLifecycleService = new ComponentLifecycleService(operationService);
sagaMiddleware.run(function* () {
yield* call(operationService.run);
yield* call(componentLifecycleService.run);
});
ReactDOM.render(
<Root operationService={operationService} componentLifecycleService={componentLifecycleService}>
<Provider store={store}>
<App />
</Provider>
</Root>,
window.document.getElementById('app')
);
The core data structure which represents some part of your application state is AsyncOperation
.
type AsyncOperation<TRes = unknown, TArgs = unknown[], TMeta = unknown, TErr = Error> = {
id: OperationId<TRes, TArgs, TMeta, TErr>; // uniq id
isLoading?: boolean; // is operation in process
isError?: boolean; // was operation finished with error
isBlocked?: boolean; // should operation be executed
error?: TErr; // error if any
args?: TArgs; // arguments operation was executed with
result?: TRes; // result of operation if it was finished
meta?: TMeta; // any additional data
};
So the whole state of the application is represented by dictionary of such operations accessible by their id.
ID of operation is described by custom type OperationId<TRes, TArgs, TMeta, TErr>
that extends string
with operation description, so it could be retrieved by type system by ID only. It is just a string at runtime.
const id = 'MY_ID' as OperationId<MyRes>;
const str: string = id; // OK by all info lost
...
operation.id = str; // TYPE ERROR id has to be of OperationId type
Services are primary containers for your business logic, they are represented by classes, which are inherited from base Service
class.
class Service<TRunArgs extends any[] = [], TRes = void> {
constructor(operationService: OperationService): Service;
*run(...args: TRunArgs): TRes; // inits service and sets status to "ready"
*destroy(...args: TRunArgs): void; // cleanup service and sets status to "unavailable"
getStatus(): 'unavailable' | 'ready';
getUUID(): string; // uniq service id
}
So custom service could be defined like this
import { Service } from '@iiiristram/sagun';
class MyService extends Service {
// each service has to override "toString" with custom string.
// it is used for actions and operations generation.
// it should be defined as class method NOT AS PROPERTY.
toString() {
return 'MyService';
}
*foo(a: Type1, b: Type2) {
// your custom logic
}
}
To make service initialized you should invoke useService
in the root of components subtree, where this service required, for example in a page root component
import { useDI, useService, Operation } from '@iiiristram/sagun';
function HomePage() {
const context = useDI();
// create instance of service resolving all its dependencies
const service = context.createService(MyService);
// register service in order it could be resolved as dependency for other services
context.registerService(service);
// init service
const { operationId } = useService(service);
return (
// await service initialization
<Operation operationId={operationId}>{() => <Content />}</Operation>
);
}
In order to save method result to redux store, method has to be marked with @operation
decorator
// MyService.ts
import { Service, operation } from '@iiiristram/sagun';
class MyService extends Service {
toString() {
return 'MyService';
}
@operation
*foo() {
return 'Hello';
}
}
// MyComponent.tsx
import { useServiceConsumer, useOperation, getId } from '@iiiristram/sagun';
function MyComponent() {
// resolve service instance, that was registered somewhere in parent components
const { service } = useServiceConsumer(MyService);
const operation = useOperation({
operationId: getId(service.foo), // get operation id from service method
});
return <div>{operation?.result} World</div>;
}
In order to be able to trigger method from UI by redux action, this method has to be marked with @daemon
decorator
import { Service, daemon } from '@iiiristram/sagun';
class MyService extends Service {
toString() {
return 'MyService';
}
@daemon()
*foo(a: number, b: number) {
console.log('Invoked with', a, b);
}
}
// MyComponent.tsx
import { useServiceConsumer } from '@iiiristram/sagun';
function MyComponent() {
const { actions } = useServiceConsumer(MyService);
return <button onClick={() => actions.foo(1, 2)}>Click me</button>;
}
It is possible to declare service dependencies via constructor arguments.
Each dependency should be ether an instance of some class, that extends Dependency
class, or associated with a string uniq constant (dependency key).
Service
class has been already inherited from Dependency
.
Service with custom dependencies should mark them with @inject
decorator.
// Service1.ts
import {Service} from '@iiiristram/sagun';
class Service1 extends Service {
toString() {
return 'Service1'
}
...
}
// CustomClass.ts
import {Dependency} from '@iiiristram/sagun';
class CustomClass extends Dependency {
toString() {
return 'CustomClass'
}
...
}
// customDependency.ts
import {DependencyKey} from '@iiiristram/sagun';
export type Data = {...}
export const KEY = '...' as DependencyKey<Data>
export const DATA: Data = {...}
// somewhere in react components
...
const di = useDI();
// register custom dependency by key
di.registerDependency(KEY, DATA);
// create instance of Dependency resolving all its dependencies
const service1 = context.createService(Service1);
const custom = context.createService(CustomClass);
// register Dependency instancies so they could be resolved as dependencies for other services
context.registerService(service1);
context.registerService(custom);
// create service with resolved dependencies after their registration
const service2 = context.createService(Service2);
...
// Service2.ts
import {Service, inject} from '@iiiristram/sagun';
class Service2 extends Service {
private _service1: Service1
private _customClass: CustomClass
private _data: Data
toString() {
return 'Service2'
}
constructor(
// default dependency for all services
@inject(OperationService) operationService: OperationService,
@inject(Service1) service1: Service1,
@inject(CustomClass) customClass: CustomClass,
@inject(KEY) data: Data
) {
super(operationService)
this._service1 = service1
this._customClass = customClass
this._data = data
}
...
}
It's possible to customize service initialization and cleanup by overriding run
and destroy
methods
class MyService extends Service<MyArgs, MyRes> {
toString() {
return 'MyService'
}
*run(...args: MyArgs) {
// IMPORTANT
yield call([this, super.run]);
const result: MyRes = ...;
return result;
}
*destroy(...args: MyArgs) {
// IMPORTANT
yield call([this, super.destroy]);
...
}
...
}
class MyService extends Service<MyArgs, MyRes> {
// OPTIONAL
private _someOtherService: MyOtherService
// REQUIRED
toString() {
return 'MyService'
}
// OPTIONAL
constructor(
@inject(OperationService) operationService: OperationService,
@inject(MyOtherService) someOtherService: MyOtherService
) {
super(operationService)
this._someOtherService = someOtherService;
}
// OPTIONAL
*run(...args: MyArgs) {
yield call([this, super.run]);
yield call(this._someOtherService.run)
const result: MyRes = ...;
return result;
}
// OPTIONAL
*destroy(...args: MyArgs) {
yield call([this, super.run]);
yield call(this._someOtherService.destroy)
}
@daemon() // make method reachable by redux action
@operation // write result to redux state
*foo(a: Type1, b: Type2) {
// your custom logic
}
}
This decorator create on operation in redux store for a wrapped service method.
// MyService.ts
import {Service, operation, OperationId} from '@iiiristram/sagun';
export const MY_CUSTOM_ID = 'MY_CUSTOM_ID' as OperationId<number>
class MyService extends Service {
toString() {
return 'MyService'
}
// create an operation with auto-generated id,
// which can be retrieved by util "getId"
@operation
*method_1() {
...
}
// create an operation with provided id,
// i.e. it's possible to assign same operation for different methods
@operation(MY_CUSTOM_ID)
*method_2() {
return 1;
}
// create an operation id depending on arguments provided for method
@operation((...args) => args.join('_') as OperationId<number>)
*method_3(...args) {
return 1;
}
@operation({
// optional, could be constant or function
id,
// optional, function that allows to change operation values,
// but it should not change operation generics
// (ie if operation result was a number it should be a number after change)
updateStrategy: function*(operation) {
const changedOperation = ... // change operation somehow
return changedOperation
},
ssr: true, // enable execution on server
})
*method_4(...args) {
return 1;
}
}
Update strategy example
// MyService.ts
import { Service, operation, OperationId } from '@iiiristram/sagun';
class MyService extends Service {
toString() {
return 'MyService';
}
@operation({
updateStrategy: function* mergeStrategy(next) {
const prev = yield select(state => state.asyncOperations.get(next.id));
return {
...next,
result: prev?.result && next.result ? [...prev.result, ...next.result] : next.result || prev?.result,
};
},
})
*loadList(pageNumber: number) {
const items: Array<Entity> = yield call(fetch, { pageNumber });
return items;
}
}
This decorator provide some meta-data for method, so it could be invoked by redux action after service.run
called.
Decorator doesn't affect cases when method directly called from another saga, all logic applied only for redux actions.
import {Service, daemon, DaemonMode} from '@iiiristram/sagun';
class MyService extends Service {
toString() {
return 'MyService'
}
// by default method won't be called until previous call finished (DaemonMode.Sync).
// i.e. block new page load until previous page loaded
@daemon()
*method_1(a: number, b: number) {
...
}
// cancel previous call and starts new (like redux-saga takeLatest)
// i.e. multiple clicks to "Search" button
@daemon(DaemonMode.Last)
*method_2(a: number, b: number) {
...
}
// call method every time, no order guarantied (like redux-saga takeEvery)
// i.e. send some analytics
@daemon(DaemonMode.Every)
*method_3(a: number, b: number) {
...
}
// has no corresponding action,
// after service run, method will be called every N ms, provided by second argument
// i.e. make some polling
@daemon(DaemonMode.Schedule, ms)
*method_4() {
...
}
@daemon(
// DaemonMode.Sync / DaemonMode.Last / DaemonMode.Every
mode,
// provide action to trigger instead of auto-generated action,
// same type as redux-saga "take" effect accepts
action
)
*method_5(a: number, b: number) {
...
}
}
This decorator has to be applied to arguments of service's constructor in order service dependencies could be resolved.
// MyService.ts
import {Service, inject} from '@iiiristram/sagun';
class MyService extends Service {
...
constructor(
// default dependency for all services
@inject(OperationService) operationService: OperationService,
@inject(MyOtherService) myOtherService: MyOtherService
) {
super(operationService)
...
}
...
}
Binds saga execution to component lifecycle. Executed same way as useEffect
.
Should be used to execute some application logic like form initialization, or to aggregate
multiple methods of services
function MyComponent(props) {
const {a, b} = props;
// operationId to subscribe to onLoad results
const {operationId} = useSaga({
// executes after reconciliation process finished
onLoad: function*(arg_a, arg_b) {
console.log('I am rendered')
yield call(service1.foo, arg_a)
yield call(service2.bazz, arg_b)
},
// executes before new reconciliation
onDispose: function*(arg_a, arg_b) {
console.log('I was changed')
}
// arguments for sagas, so sagas re-executed on any argument change
}, [a, b])
...
}
If changes happened in the middle of long running onLoad
, this saga will be canceled (break on nearest yield) and onDispose
will be called.
It is guaranteed that onDispose
will be fully executed before next onLoad
, so if changes happened multiple times during long running onDispose
, onLoad
will be called only once with latest arguments. onLoad
is wrapped into operation, so you are able to subscribe to its execution using operationId
, provided by the hook.
const { operationId } = useService(service, [...args]);
This is shortcut for
const { operationId } = useSaga(
{
onLoad: service.run,
onDispose: service.dispose,
},
[...args]
);
This hook retrieves service by its constructor, and create corresponding redux actions to invoke methods, marked by @daemon
decorator. Actions are bond to store, so no dispatch
necessary.
import { Service, daemon } from '@iiiristram/sagun';
class MyService extends Service {
toString() {
return 'MyService';
}
@daemon()
*foo(a: number, b: number) {
console.log('Invoked with', a, b);
}
}
// MyComponent.tsx
import { useServiceConsumer } from '@iiiristram/sagun';
function MyComponent() {
const { actions } = useServiceConsumer(MyService);
return <button onClick={() => actions.foo(1, 2)}>Click me</button>;
}
This hook creates a subscription to operation in the redux store. It is compatible with React.Suspense
, so it's possible to fallback to some loader while operation is executing.
// MyService.ts
import { Service, operation } from '@iiiristram/sagun';
class MyService extends Service {
toString() {
return 'MyService';
}
@operation
*foo() {
return 'Hello';
}
}
// MyComponent.tsx
import { useServiceConsumer, useOperation, getId } from '@iiiristram/sagun';
function MyComponent() {
const { service } = useServiceConsumer(MyService);
const operation = useOperation({
operationId: getId(service.foo),
suspense: true, // turn on Suspense compatibility
});
return <div>{operation?.result} World</div>;
}
// Parent.tsx
function Parent() {
return (
<Suspense fallback="">
<MyComponent />
</Suspense>
);
}
Before using the hook your should provide path in store, where to look for operation.
// bootstrap.ts
useOperation.setPath(state => ...) // i.e. state => state.asyncOperations
This hook return a context which is primally used to register and resolve dependencies for your services. Context API looks like
type IDIContext = {
// register custom dependency by key
registerDependency<D>(key: DependencyKey<D>, dependency: D): void;
// get custom dependency by key
getDependency<D>(key: DependencyKey<D>): D;
// register dependency instance
registerService: (service: Dependency) => void;
// create dependency instance resolving all sub-dependencies,
// in case they were registered before, throw an error otherwise
createService: <T extends Dependency>(Ctr: Ctr<T>) => T;
// retrieve dependency instance if it was registered,
// throw an error otherwise
getService: <T extends Dependency>(Ctr: Ctr<T>) => T;
// create actions for service methods marked by @daemon,
// bind them to store if any provided
createServiceActions: <T extends BaseService<any, any>>(service: T, bind?: Store<any, AnyAction>) => ActionAPI<T>;
};
This component provides all necessary contexts. You have to wrap your application with it.
import {
ComponentLifecycleService,
OperationService,
Root,
} from '@iiiristram/sagun';
...
const operationService = new OperationService();
const componentLifecycleService = new ComponentLifecycleService(operationService);
ReactDOM.render(
<Root
operationService={operationService}
componentLifecycleService={componentLifecycleService}
>
<App />
</Root>,
window.document.getElementById('app')
);
This component encapsulates useOperation
import {useSaga, Operation} from '@iiiristram/sagun';
function MyComponent() {
const {operationId} = useSaga({
onLoad: function* () {
// do something
}
);
return (
// await service initialization
<Operation operationId={operationId}>
{() => <Content/>}
</Operation>
)
}
Provides IoC container, you shouldn't use this context directly, there is hook useDI
for this purpose.
Provides boolean flag, if false
no sagas will be executed on server in a children subtrees.
Encapsulates saga binding with operation subscription.
Uses useSaga
, useOperation
, useDI
and Suspense
inside.
const MyComponent = withSaga({
// factory provided with DIContext
sagaFactory: ({ getService }) => ({
onLoad: function* (id: string) {
const service = getService(MyService);
return yield call(service.fetch, id);
},
}),
// converts component props to useSaga "args" list
argsMapper: ({ id }: Props) => [id],
})(({ operation }) => {
// rendered after operation finished,
return <div>{operation.result}</div>;
});
const Parent = () => {
// fallback to Loader till operation not finished
return <MyComponent id="1" fallback={<Loader />} />;
};
Encapsulates saga binding with operation subscription.
Uses useService
, useServiceConsumer
, useDI
, useOperation
and Suspense
inside.
const MyComponent = withService({
// factory provided with DIContext
serviceFactory: ({ createService }) => {
return createService(MyService);
},
// converts component props to useService "args" list
argsMapper: ({ id }: Props) => [id],
})(({ operation, service, action }) => {
// rendered after service registered and initialized,
return <div onClick={() => actions.foo()}>{service.getStatus()}</div>;
});
const Parent = () => {
// fallback to Loader till operation not finished
return <MyComponent id="1" fallback={<Loader />} />;
};
In order to make your sagas work with SSR you should do the following
// MyService.ts
class MyService extends Service {
@operation({
// Enable ssr for operation, so it's result will be collected.
// Operations marked this way won't be executed on client at first time,
// so don't put here any logic with application state, like forms,
// such logic probably has to be also executed on the client.
// You should collect pure data here.
ssr: true
})
*fetchSomething() {
//
}
}
// MyComponent.tsx
function MyComponent() {
const {operationId} = useSaga({
onLoad: myService.fetchSomething,
})
return (
// subscribe to saga that contains the operation via Operation or useOperation,
// if no subscription, render won't await this saga
<Operation
// getId(myService.fetchSomething) also could be used
operationId={operationId}
>
{({result}) => <Content result={result}/>}
</Operation>
)
}
// App.tsx
function App() {
return (
// ensure there is Suspense that will handle your operation
<Suspense fallback="">
<MyComponent/>
</Suspense>
)
}
// server.ts
import { renderToStringAsync } from '@iiiristram/serverRender';
useOperation.setPath(state => state);
const render = async (req, res) => {
const sagaMiddleware = createSagaMiddleware();
const store = applyMiddleware(sagaMiddleware)(createStore)(
asyncOperationsReducer
);
// provide "hash" option
const operationService = new OperationService({ hash: {} });
const componentLifecycleService = new ComponentLifecycleService(operationService);
const task = sagaMiddleware.run(function* () {
yield* call(operationService.run);
yield* call(componentLifecycleService.run);
});
// this will incrementally render application,
// awaiting till all Suspense components resolved
const html = await renderToStringAsync(
<Root
operationService={operationService}
componentLifecycleService={componentLifecycleService}
>
<Provider store={store}>
<App />
</Provider>
</Root>
);
// cleanup sagas
task.cancel();
await task.toPromise();
res.write(`
<html>
<body>
<script id="state">
window.__STATE_FROM_SERVER__ = ${JSON.stringify(store.getState())};
</script>
<script id="hash">
window.__SSR_CONTEXT__ = ${JSON.stringify(operationService.getHash())};
</script>
<div id="app">${html}</div>
</body>
</html>
`.trim());
res.end();
});
// client.ts
const sagaMiddleware = createSagaMiddleware();
const store = applyMiddleware(sagaMiddleware)(createStore)(
asyncOperationsReducer,
window.__STATE_FROM_SERVER__
);
const operationService = new OperationService({ hash: window.__SSR_CONTEXT__ });
const componentLifecycleService = new ComponentLifecycleService(operationService);
sagaMiddleware.run(function* () {
yield* call(operationService.run);
yield* call(componentLifecycleService.run);
});
useOperation.setPath(state => state);
ReactDOM.hydrate(
<Root operationService={operationService} componentLifecycleService={service}>
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
</Root>,
window.document.getElementById('app'),
);