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.Ā