Skip to main content

Editing Data

Earlier we looked at how to read data, but we also need to react to user actions to modify data.

The daemon Decorator

Let's create another entity — Order. Besides loading orders, we can also create a new order.

To call service methods outside of sagas, we'll use the daemon decorator:

class OrderService extends Service {
toString() {
return "OrderService"
}

@operation
*getOrders() {
return yield* call(fetchOrders);
}

// Creates a redux action for this method.
// This allows us to call it from anywhere, not just from useSaga
@daemon()
*addOrder() {
const id = getNewId();
yield* call(addOrder, {id, description: `Order ${id}`});
// update order data in store
yield* call(this.getOrders);
}
}

Let's add our service to the application:

function App({children}) {
const di = useDI();

const userService = di.createService(UserService);
di.registerService(userService)

const orderService = di.createService(OrderService);
di.registerService(orderService)

const {operationId} = useService([userService, orderService]);

return (...);
}

Now let's write an interface that provides order management.

warning

It's not recommended to call service methods from other sagas via actions, as such calls won't be cancelled when the parent saga is cancelled.

useSaga({ 
onLoad: function * () {
// wrong, on component unmount onLoad will be cancelled, but foo won't
actions.foo();
// correct
yield call(service.foo)
}
})
tip

The default behavior of the daemon decorator is that the method won't be called while the current call is still running (applies only to calls via actions). This helps avoid redundant triggers out of the box — extra button clicks, multiple scroll events during pagination, etc.

This behavior can be changed by specifying decorator arguments, read more in the documentation.

Operation Strategies

You may notice that each time we add an order, we see a loader — this is not great UX. Plus, we're making unnecessary requests for the order list. Let's make order addition on the client side.

For this, we can write an operation update strategy that can transform data before writing it to the store. The strategy has a simple contract — it takes operation data as input and should return it in the same format, including the operation result type.

// Create an explicit ID for operations on the order list.
// This allows us to edit the same data in the store with different methods.
const ORDERS_OPERATION_ID = 'orders' as OperationId<Order[]>

// describe a strategy that solves two problems:
// - adds new orders to the list
// - by default, each time an async operation runs, its previous result is reset;
// we explicitly describe that during loading the previous result should be returned,
// this eliminates the loader in the UI
function* appendStrategy(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,
};
}

class OrderService extends Service {
// mark all methods that will edit the list with our ID
@operation(ORDERS_OPERATION_ID)
*getOrders() { ... }

@daemon()
@operation({
id: ORDERS_OPERATION_ID
updateStrategy: appendStrategy
})
*addOrder() {
const id = getNewId();
const order = {id, description: `Order ${id}`};

yield* call(addOrder, order);
// return the created order to add it to the list
return [order];
}
}
tip

You can write many reusable strategies for typical cases — adding/removing from a list, various data merging, etc.

This makes service methods lighter and more readable, and declaratively describes how their execution results will be processed.

Let's test our solution: