DEV Community

Eduard Krivanek
Eduard Krivanek

Posted on

Angular: Generic In-Memory Cache Service

When manage application state in you Angular projects, you create a service to handle a specific feature state, such as booking state, user state, groups, etc.

However there are cases where you have such a little data that it’s not worth creating a separate service, but rather have one service where you keep some general application information, and maybe synchronize with local storage.

You want to have a strongly typed service where you can save pieces of data under specific keys and sync it with local storage, so how to go around it? First create you types and initial late:

export type LocalStorageData = {
  /** user's demo account */
  demoAccount?: {
    email: string;
    password: string;
    createdDate: string;
  };
  /** true if should show loader on the whole app */
  loderState?: {
    enabled: boolean;
  };
  theme?: {
      isDarkMode: boolean;
  }
};

export const storageInitialData: LocalStorageData = {
  demoAccount: undefined,
  loderState: undefined,
  theme: undefined
};
Enter fullscreen mode Exit fullscreen mode

in this case LocalStorageData represents what data type we want to save into the store service and storageInitialData is the initial store data. Then to create a storage service, you can go as follows:

@Injectable({
  providedIn: 'root',
})
export class StorageLocalService {
    /** key under which the data is saved in local storage */
  private readonly STORAGE_MAIN_KEY = 'APPLICATION_NAME';

  private readonly updateData$ = new Subject<LocalStorageData>();

  /** current version of the data saved - if changed, all data will be removed */
  private readonly currentVersion = 1;

  /** readonly value from local storage */
  readonly localData = toSignal(this.updateData$.pipe(startWith(this.getDataFromLocalStorage())), {
    initialValue: this.getDataFromLocalStorage(),
  });

  /**
   * saves data also into local storage
   *
   * @param key - key to save data
   * @param data - data to be saved
   */
  saveDataLocal<T extends keyof LocalStorageData>(key: T, data: LocalStorageData[T]): void {
    try {
      const newData = this.saveAndReturnState(key, data);

      // can happen that too many data is saved
      localStorage.setItem(this.STORAGE_MAIN_KEY, JSON.stringify(newData));
    } catch (e) {
      console.log(e);
    }
  }

  /**
   * saves data into local internal variable
   * @param key
   * @param data
   */
  saveData<T extends keyof LocalStorageData>(key: T, data: LocalStorageData[T]): void {
    this.saveAndReturnState(key, data);
  }

  private saveAndReturnState<T extends keyof LocalStorageData>(key: T, data: LocalStorageData[T]): LocalStorageData {
    // all local storage data saved for this app - different keys
    const savedData = this.getDataFromLocalStorage();

    // updated data for this specific key
    const newData = {
      ...savedData,
      [key]: data,
    };

    // notify all subscribers
    this.updateData$.next(newData);

    return newData;
  }

  /** returns stored app state or initial data if versions do not match */
  private getDataFromLocalStorage(): LocalStorageData {
    const data = localStorage.getItem(this.STORAGE_MAIN_KEY) ?? '{}';
    const dataParsed = JSON.parse(data) as LocalStorageKeysVersion;

    // if version matches, return data
    if (dataParsed.version === this.currentVersion) {
      return dataParsed;
    }

    // create new initial data since version is different
    const updatedData = {
      ...storageInitialData,
      version: this.currentVersion,
    };

    // update local storage
    localStorage.setItem(this.STORAGE_MAIN_KEY, JSON.stringify(updatedData));

    return updatedData;
  }
}
Enter fullscreen mode Exit fullscreen mode

Couple of things to mention about the above service:

The use of currentVersion is important because it may happen that you want to change the data structure for a specific key or completely reset the local storage data. The method getDataFromLocalStorage() checks if the version that is in the local storage matches the version of the service and if not, it will reset the whole stored data. You also may want to introduce a version for each specific key to not remove all the stored data.

You need to have two methods - saveData() that will only save some data into the in-memory state, but also a method that will persist the data, like saveDataLocal() . Keep in mind that you may have more data than the maximum capacity of the local storage (~10MB) so don’t save everything in the local storage.

Using generics we can achieve a strongly typed service with the following <T extends keyof LocalStorageData>. For example for when I use the saveData() method, I choose a key from the LocalStorageData type and TS will tell me what data type I can save

Finally the exposed signal localData that has the current state value. Use signals rather than observables to handle state.

@Component({
  selector: 'app-page-menu',
  standalone: true,
  imports: [ /* .... */  ],
  template: `
      @if(loading()) {
        show loader
      }
  `
})  
export class PageMenuComponent {
  private storageLocalService = inject(StorageLocalService);
  loading = computed(() => !!this.storageLocalService.localData()?.loader?.enabled);
}
Enter fullscreen mode Exit fullscreen mode

Hope you find this small example helpful.

Top comments (2)

Collapse
 
railsstudent profile image
Connie Leung

what a good use of Subject in this demo. Excellent job, Eduard.

Collapse
 
jangelodev profile image
João Angelo

Hi Eduard Krivanek,
Top, very nice and helpful !
Thanks for sharing.