Auto Save Architecture

April 15, 2018

Goal

The purpose of this document is to outline an approach for building an auto-save feature in a web application. This feature allows data updates to be automatically saved to the database when a user stops typing. Each entry’s validity is verified, ensuring that only valid data is saved, while also tracking changes to prevent redundant save operations.

Overview of the Solution

Auto-save functionality involves:

  1. Detecting when the user pauses typing and triggering a save action.
  2. Validating each data entry before saving.
  3. Managing UI states to track if data is synced, being saved, or needs to be saved again.

Technical Requirements

Each row in a data table represents a unique entry, requiring:

  1. Automatic saving when a user pauses typing for a specific duration.
  2. Validation for each entry before it’s sent to the API.
  3. The ability to add or delete rows as needed.

Core Properties and Terminology

Property Purpose
guid A unique identifier for each row, used for UI tracking and syncing entries with the database.
isValid Indicates if an entry passes validation. Only valid entries will be saved.
isSynced Tracks if an entry’s data has been saved to the database.
isSavingInProgress Indicates if a save operation is currently underway for a specific entry.

These properties are fundamental to effectively managing each entry's save state.

Data Entry Save Conditions

For a row (referred to as DataEntry) to be saved, it must meet these conditions:

  1. isValid = true: Entry passes validation.
  2. isSynced = false: Data has been modified since the last save.
  3. isSavingInProgress = false: There’s no ongoing save request for the entry.

A memoized selector aggregates rows that match these conditions. Subscribing to this selector triggers save requests, optimizing performance by only saving necessary entries.

Life Cycle of a DataEntry

The lifecycle below illustrates how a DataEntry moves through the auto-save process. Each step aligns with the required application state changes, ensuring data consistency and minimizing redundant saves.

Step 1: Load Data on Page Load

Upon application start, a request fetches all data entries from the database. Each entry receives a guid to uniquely track it in the UI.

{
  ...DataEntry,
  guid: '39a0689e-a44b-ab92-94fb-194408f30e0e'
}

Step 2: Initial Validation of Data

Once data is loaded, the application validates each entry, marking isValid as true or false to indicate validity and provide feedback if any issues are found.

{
  ...DataEntry,
  isValid: true/false
}

Step 3: Data Ready for Interaction

The application waits for user interaction. No save requests are triggered at this point.

Step 4: User Modifications

As a user modifies a DataEntry, Redux updates the entry’s state, setting isSynced = false and adjusting its validity.

{
  ...updatedDataEntry,
  isValid: true/false,
  isSynced: false
}

Step 5: Auto Save Trigger

After user input, if no changes are detected for a specified duration (debounced at 500ms), an auto-save action triggers, targeting rows that meet the save conditions.

const dataEntriesRequiringSavingSelector = createSelector(
  dataEntriesSelector,
  (dataEntries) =>
    dataEntries.filter(
      (item) => !item.isSynced && item.isValid && !item.isSavingInProgress
    )
);
 
const autoSaveDebounce = 500;
function isArrayNotEmpty(array: any[]) {
  return array.length > 0;
}
 
class DataService {
  constructor(private store: Store<IStore>) {
    store
      .select(dataEntriesRequiringSavingSelector)
      .pipe(filter(isArrayNotEmpty), debounceTime(autoSaveDebounce))
      .subscribe((dataEntriesRequiringSaving) => {
        store.dispatch({
          type: 'SAVE_REQUEST_TABLE_ROW_ENTRIES',
          payload: dataEntriesRequiringSaving,
        });
      });
  }
}

Step 6: Auto Save Request Sequence

When the save action dispatches, an API call is triggered to store these entries in the database. Below is an effect handling the save request and dispatching a response upon completion.

@Effect()
saveTableRowEntries$: Observable<Action> = this.actions$
  .ofType('SAVE_REQUEST_TABLE_ROW_ENTRIES')
  .pipe(
    switchMap((action) =>
      SaveTableRowEntriesApiRequest(action.payload).pipe(
        map((apiResponse) => ({
          type: 'SAVE_RESPONSE_TABLE_ROW_ENTRIES',
          payload: {
            response: apiResponse,
            request: action.payload,
          },
        })),
        catchError((error) => of({ type: 'SAVE_FAILED', error }))
      )
    )
  );

Step 7: Update Save Status

When SAVE_RESPONSE_TABLE_ROW_ENTRIES is dispatched, the reducer updates isSavingInProgress to false, and, if no further changes were made during the save, sets isSynced = true. If new changes occurred during the save, it returns to Step 5.

const request = action.payload.request;
const response = action.payload.response;
 
dataEntries.map((DataEntry) =>
  request.find((savedRow) => savedRow.guid === DataEntry.guid)
    ? {
        ...DataEntry,
        id: response.find((resRow) => resRow.guid === DataEntry.guid).id,
        isSavingInProgress: false,
      }
    : DataEntry
);

Error Handling

If a save fails, retry the save or notify the user. Ensuring each failed entry is re-saved upon the next valid state change helps maintain data consistency.


By following these steps, you can implement a robust auto-save feature that reduces redundant saves, ensures data integrity, and enhances the user experience with real-time data updates similar to Google Sheets.


Roman Khrystynych

Written by Roman Khrystynych who lives and works in Toronto building interesting things.