Angular Autosave for Forms, Services and NgRx

June 26, 2020

web developmentfrontendangular

Saving a user's changes automatically improves user-experience by preventing data loss. Let's see how we can implement autosave behaviors with Angular.

Autosave vs. Caching

I'm referring to autosave as automatically storing a serialization of user input data on the server-side or at least somewhat persistently outside of Angular - e.g. in LocalStorage or IndexedDB.

If you'd just like to cache the state of input fields between in-app navigations, you might already be fine with implementing a custom RouteReuseStrategy in order to re-use component state.

Similarly, you can retain component state by binding to a service that outlives the component. This could either be a custom service based solely on change detection or leveraging RxJS behavior subjects as well as something like NgRx store.

Form Autosave

Let's see how we can actually autosave forms in Angular. Since the framework leverages RxJS we're already in a pretty good situation to reactively save data upon value changes.

When you're using reactive forms, any AbstractControl (e.g. a FormGroup or single FormControl) will expose an observable property valueChanges. Sadly, just like any other form API, this observable is still typed as any despite emitting the value object of your form. Recently, the Angular team announced their work on strongly typed forms, so this might get better soon!

valueChanges: Observable<any>, A multicasting observable that emits an event every time the value of the control changes, in the UI or programmatically -- Angular Documentation

In order to facilitate autosave, you can now easily subscribe to this observable, map the form value to something your server understands, and send off the data.

But not so fast, please don't subscribe manually from inside a subscription callback. This is error-prone and might not yield the result you're looking for. Instead, let's choose the proper RxJS operators for your intended autosave behavior.

@Component({...})
export class MyComponent implements OnInit, OnDestroy {

    form: FormGroup

    private unsubscribe = new Subject<void>()

    constructor(private service: MyService) {}

    ngOnInit() {
        this.form = /* create reactive form */;
        this.form.valueChanges.pipe(
            switchMap(formValue => service.save(formValue)),
            takeUntil(this.unsubscribe)
        ).subscribe(() => console.log('Saved'))
    }

    ngOnDestroy() {
        this.unsubscribe.next()
    }
}

In the snippet above, every change to the form will trigger a save call. Yet, due to the use of switchMap, only the most recent save call will be active at one point in time. Subsequent value changes will cancel prior save calls when these haven't completed yet.

We could replace switchMap with mergeMap and thus have all created autosave requests run simultaneously. Similarly, we might use concatMap to execute the save calls one after another. Another option might be exhaustMap which would ignore value changes until the current save call is done.

Either way, since we're dealing with a long-lived observable (meaning it doesn't just emit one time but indefinitely), we should unsubscribe from the stream once the component encapsulating our form is destroyed. In the snippet above I'm doing this with the takeUntil operator.

What I'd like to do is save only the most recent version of the form while throttling value changes using the debounceTime operator. Debouncing with 500 means that the observable will only start a save call when 500ms passed without further value changes.

this.form.valueChanges.pipe(
    debounceTime(500),
    switchMap(formValue => service.save(formValue)),
    takeUntil(this.unsubscribe)
).subscribe(() => console.log('Saved'))

If you'd like to run a save periodically while the user is constantly inputting data, you can instead use the auditTime or throttleTime operator.

Join my mailing list and follow me on Twitter @n_mehlhorn for more in-depth Angular & RxJS knowledge

Autosave for Subject Services

When you're handling state through any kind of RxJS subject in a service, you can apply the same principle. Just pipe the subject using the operator combination that fits the behavior you want to achieve.

The following service will autosave any setting changes periodically after 1s while they occur thanks to auditTime. The concatMap operator makes sure that none of the save requests are cancelled while keeping them in chronological order.

export interface Settings {
    darkMode: boolean
}

export class SettingsService implements OnDestroy {

    private unsubscribe = new Subject<void>()

    private settings = new BehaviorSubject<Settings>({darkMode: false})

    public settings$ = this.settings.asObservable()

    constructor(private service: MyService) {
        this.settings.pipe(
            auditTime(1000),
            concatMap(settings => service.save(settings)),
            takeUntil(this.unsubscribe)
        ).subscribe(() => console.log('Saved'))
    }

    setDarkMode(darkMode: boolean) {
        this.settings.next({...this.settings.getValue(), darkMode})
    }

    ngOnDestroy() {
        this.unsubscribe.next()
    }
}

NgRx Autosave

When using NgRx, autosave is best implemented as an effect.

Effects are where you handle tasks such as fetching data, long-running tasks that produce multiple events, and other external interactions where your components don't need explicit knowledge of these interactions. -- NgRx Documentation

The specific approach I've chosen for SceneLab is to list all actions that modify the state that should be autosaved. We already did something similar to implement undo-redo with ngrx-wieder.

const STATE_MODIFYING_ACTIONS = [
    addElementSuccess,
    undo,
    redo,
    resizeSelection
    ...
]

Then we can create an effect that listens for any of these actions by initializing the ofType operator with the spread syntax.

autosave$ = createEffect(() => this.actions$.pipe(
    ofType(...STATE_MODIFYING_ACTIONS),
    debounceTime(500),
    map(() => MyActions.save())
))

After being debounced, the effect will create a saving action which we will handle in a separate effect. This allows us to easily trigger a save from other places while properly separating concerns. The actual save effect will eventually look very much how you'd write any asynchronous effect for NgRx. I'm also using withLatestFrom to access the latest state to save.

save$ = createEffect(() => this.actions$.pipe(
    ofType(MyActions.save),
    withLatestFrom(this.store)
    switchMap(([action, state]) => this.service.save(state)),
    map(() => MyActions.saveSuccess())
))

Note that the save call could produce errors which you might want to handle differently than NgRx. By default it will re-subscribe to the effect observable up to 10 times.

📕 I've written a book on NgRx. Learn how to structure your state, write testable reducers and work with actions and effects from one well-crafted resource.

Meanwhile, we can also manage a flag in our state indicating to the user that we are currently saving their data.

const myReducer = createReducer(initialState,
    on(...STATE_MODIFYING_ACTIONS, state => {
        return {...state, saved: false}
    }),
    on(MyActions.saveSuccess, state => {
        return {...state, saved: true}
    })
)
@Component({...})
export class MyComponent implements OnInit, OnDestroy {

    saved$ = this.store.select(state => state.saved)

    constructor(private store: Store<State>) {}
}
<p *ngIf="saved$ | async; else saving">saved</p>
<ng-template #saving>
    <p>saving...</p>
</ng-template>

If you want to get the UX here perfectly right and not display 'saving...' before the user has made any changes, you'll also have to manage a pristine flag similar to the one from Angular forms.

Here's how this looks for SceneLab where the indicator is located in the header:

Angular & NgRx autosave demo with SceneLab

HTTP or WebSocket? LocalStorage?

I've been using service.save(state) as a placeholder for making the HTTP server request that persists data. However, you might be wondering whether HTTP is the right protocol to use for autosave - so am I. From my perspective, there are two aspects to consider:

  • payload size
  • request frequency

Since HTTP has a moderate overhead per request, it'd be better fit for lower request frequencies while the payload size can be arbitrarily big. However, you probably want to keep the payload size - just like any serialization efforts - possibly low anyways for good performance.

Websockets, on the other hand, open a connection once in order to send only minimal messages after that. Therefore it'd be better for higher request frequencies with smaller payloads. Websockets are especially useful for pushing data from the server to the client e.g. for a chat application. Though, for autosave, we only need to send client data to the server.

Yet, what are lower and higher request frequencies? I'd argue that with a debounced implementation based on user-changes, the save frequency won't be all that high. Therefore I'd advise you to try out an HTTP-based solution before jumping into a new protocol involving long-lived connections that your servers and proxies need to support - possibly at a certain scale. Make sure though, your server is using HTTP/2 to get the most out of it.

For SceneLab we went with HTTP while the payload size is usually around a few kilobytes. Try it out in the app and see how it feels (you need to be logged-in for autosaving to the server).

As a reference, Google Docs is also sending HTTP POST requests on every single keystroke.

Meanwhile, you might have a use-case where you don't need to send data to the server at all. Maybe it's enough to store the data in LocalStorage or IndexedDB. That's what we're doing in SceneLab when you're using the app without being logged-in. Once you login, it'll allow you to recover a project you've drafted up before committing to a registration.

I hope this article helped you with your project. Hire me, if you need further support solving your specific problem. Sometimes even just a quick code review or second opinion can make a great difference.

Hi, I'm Nils

Founder. Software Engineer. Author. Speaker

Essen, Germany