An AutoSave Pattern for Ember and Ember Data
Requiring users to click a submit or save button in a web application is great for developers but can be a burden on users in some situations. Sometimes it is nice to automatically save the user’s data as they fill out forms.
Automatically saving models in Ember can be a little tricky. A first pass might look like this:
App.Document = DS.Model.extend # some attributes here... saveWhenDirty: (-> @get('store').commit() if @get('isDirty') ).observes('isDirty')
This solution has some significant problems:
Everytime the user types a character an ajax request is sent to the server resulting in a flood of requests.
As the user types, Ember throws errors saying that you can’t modify attributes when a model is “inFlight”.
Let’s tackle the “too many saves” problem first. Ideally we want to save the user’s data at some logical time. In many applications saving happens when a form is submitted or when a field blurs. However handling all of the cases where we want to save can get a little finicky. On their own, the built-in Ember controls work very reliably for updating model attributes. When I started extending these views to handle events where the user would want to save (e.g. focusOut, change) I ran into some edge cases where the events would not fire. For instance, when using the back button after editing a field I found that the change event would not fire on a field with focus.
In any event we also want to handle the case where a user is editing a lot of text in a textarea and wants to save their work as they go. We want to save when the user types.
This is a good case for a debounce function. Each time a debounce function is called it resets a timer and waits to call another function. So in this case as the user types, the debounce function is called repeatedly but only fires the save function when the user pauses. Here is a simple debounce function based on underscore’s debounce.
App.debounce = (func, wait) -> timeout = null -> context = this args = arguments lastArg = args[args.length - 1] immediate = true if lastArg and lastArg.now later = -> timeout = null func.apply(context, args) clearTimeout(timeout) if immediate func.apply(context, args) else timeout = setTimeout(later, wait)
One bonus feature here is that there is a way to clear out the pending function call and execute immediately by passing {now: true} to our debounced function.
Let’s use that function to implement some saner saving.
App.DocumentController = Ember.ObjectController.extend autoSave: (-> @debouncedSave() ).observes('content.body') debouncedSave: App.debounce (-> @save()), 1000 save: -> @get('store').commit()
So now when the user stops typing for 1 second, the save function will be called.
Give Our Attributes a Buffer
Even though we’re saving less often, we’ll still get errors from Ember if the user stops typing for a second and then immediately starts typing again while the record is saving. To solve this problem we’ll have users edit a buffer that holds the attribute instead of editing it directly on the model. Since writing this code, I’ve seen this pattern called a Buffered Proxy.
#= require ./debounce BUFFER_DELAY = 1000 App.AutoSaving = Ember.Mixin.create # Buffered fields are saved after the set buffer delay. bufferedFields: [] # InstaSave fields save the model as soon as they change. instaSaveFields: [] # Setup buffers to write to instead of directly editing # the model attributes. _buffers: Ember.Map.create() # If we update a field that has been specified as one of the # bufferedFields or instaSaveFields do a safeSet to write # to a buffer if the model isSaving. setUnknownProperty: (key, value) -> @_safeSet(key, value) @_debouncedSave() if @get('bufferedFields').contains(key) @_debouncedSave(now: true) if @get('instaSaveFields').contains(key) # Pull properties from the buffer if they have been set there. unknownProperty: (key) -> if @_buffers.has(key) then @_buffers.get(key) else @_super(key) _safeSet: (key, value) -> if @_isInflight() @get('_buffers').set(key, value) else @get('content').set(key, value) _flushBuffersWhenSavingIsComplete: (-> return if @get('content.isSaving') @_flushBuffers() ).observes('content.isSaving') _flushBuffers: -> @get('_buffers').forEach (key, value) => @get('content').set(key, value) @set('_buffers', Ember.Map.create()) # Write the buffers to the actual content and save or # try saving again later. _safeSave: -> return unless @get('content.store') unless @_isInflight() @_flushBuffers() @get('content.store').commit() else @_debouncedSave() _isInflight: -> @get('content.isSaving') or @get('content.isLoading') _debouncedSave: App.debounce (-> @_safeSave()), BUFFER_DELAY # When the model is about to change out from under the controller we must # immediately save any pending changes and clear out the buffers. _saveNowAndClear: (-> return unless @get('content') @_debouncedSave(now: true) ).observesBefore('content')
This mixin can be applied in a controller to provide a buffer between the Ember fields and the model’s attributes so that users can continue to type while saving. Here is an example of this mixin in use.
App.DocumentController = Ember.ObjectController.extend App.AutoSaving, bufferedFields: ['title', 'body'] instaSaveFields: ['postedAt', 'category']
In practice this mixin has been working for me, but there is definitely some room for improvement. For one, it would be nice to let developers signal in the templates rather than in the controller whether fields should be buffered or instantly saved. This way there wouldn’t be duplication of field names in the controller and the template. One idea is to give fields special bindings like {{input value=bodyBuffered}} to determine how they should be handled.
Also I still see the possibility of losing data if the model isSaving when the model changes out from under the controller. However, in practice I haven’t been able to produce this behavior.
Have you implemented something to handle autosaving? Have ideas for improvements? Please let me know!
Thanks to @workmanw for a technical review of my code snippets.
I found a similar mixin by Tim Evans and loved how his version immediately updated the model attributes if it could. I worked that feature into my version above.
I created a Github repo for mixin to manage improvements as people suggest them.