1import * as Schema from "effect/Schema";
2import * as Record from "effect/Record";
3import { useCallback, useMemo, useSyncExternalStore } from "react";
4
5export class LocalStorageOperationError extends Schema.TaggedError<LocalStorageOperationError>()(
6 "LocalStorageOperationError",
7 {
8 operation: Schema.Literals(["read", "decode", "encode", "update", "write", "remove", "notify"]),
9 storageKey: Schema.String,
10 cause: Schema.Defect(),
11 },
12) {
13 override get message(): string {
14 return `Failed to ${this.operation} local storage item ${this.storageKey}.`;
15 }
16}
17
18const fallbackStorage: Storage = (() => {
19 const store = new Map<string, string>();
20 return {
21 clear: () => store.clear(),
22 getItem: (_) => store.get(_) ?? null,
23 key: (_) => Record.keys(store).at(_) ?? null,
24 get length() {
25 return store.size;
26 },
27 removeItem: (_) => store.delete(_),
28 setItem: (_, value) => store.set(_, value),
29 };
30})();
31
32const getStorage = (): Storage =>
33 typeof window !== "undefined" ? window.localStorage : fallbackStorage;
34
35const read = (key: string) => {
36 try {
37 return getStorage().getItem(key);
38 } catch (cause) {
39 throw new LocalStorageOperationError({ operation: "read", storageKey: key, cause });
40 }
41};
42
43const decode = <T, E>(key: string, schema: Schema.Codec<T, E>, value: string) => {
44 try {
45 return Schema.decodeSync(Schema.fromJsonString(schema))(value);
46 } catch (cause) {
47 throw new LocalStorageOperationError({ operation: "decode", storageKey: key, cause });
48 }
49};
50
51const encode = <T, E>(key: string, schema: Schema.Codec<T, E>, value: T) => {
52 try {
53 return Schema.encodeSync(Schema.fromJsonString(schema))(value);
54 } catch (cause) {
55 throw new LocalStorageOperationError({ operation: "encode", storageKey: key, cause });
56 }
57};
58
59export const getLocalStorageItem = <T, E>(key: string, schema: Schema.Codec<T, E>): T | null => {
60 const item = read(key);
61 return item === null ? null : decode(key, schema, item);
62};
63
64export const setLocalStorageItem = <T, E>(key: string, value: T, schema: Schema.Codec<T, E>) => {
65 const valueToSet = encode(key, schema, value);
66 try {
67 getStorage().setItem(key, valueToSet);
68 } catch (cause) {
69 throw new LocalStorageOperationError({ operation: "write", storageKey: key, cause });
70 }
71};
72
73export const removeLocalStorageItem = (key: string) => {
74 try {
75 getStorage().removeItem(key);
76 } catch (cause) {
77 throw new LocalStorageOperationError({ operation: "remove", storageKey: key, cause });
78 }
79};
80
81const LOCAL_STORAGE_CHANGE_EVENT = "t3code:local_storage_change";
82
83interface LocalStorageChangeDetail {
84 key: string;
85}
86
87function dispatchLocalStorageChange(key: string) {
88 if (typeof window === "undefined") return;
89 try {
90 window.dispatchEvent(
91 new CustomEvent<LocalStorageChangeDetail>(LOCAL_STORAGE_CHANGE_EVENT, {
92 detail: { key },
93 }),
94 );
95 } catch (cause) {
96 throw new LocalStorageOperationError({ operation: "notify", storageKey: key, cause });
97 }
98}
99
100export function useLocalStorage<T, E>(
101 key: string,
102 initialValue: T,
103 schema: Schema.Codec<T, E>,
104): [T, (value: T | ((val: T) => T)) => void] {
105 const getSnapshot = useCallback(() => {
106 try {
107 return read(key);
108 } catch (error) {
109 console.error("[LOCALSTORAGE] Could not read stored value.", error);
110 return null;
111 }
112 }, [key]);
113
114 const subscribe = useCallback(
115 (onStoreChange: () => void) => {
116 const handleStorageChange = (event: StorageEvent) => {
117 if (event.key === key) {
118 onStoreChange();
119 }
120 };
121 const handleLocalChange = (event: CustomEvent<LocalStorageChangeDetail>) => {
122 if (event.detail.key === key) {
123 onStoreChange();
124 }
125 };
126
127 window.addEventListener("storage", handleStorageChange);
128 window.addEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener);
129 return () => {
130 window.removeEventListener("storage", handleStorageChange);
131 window.removeEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener);
132 };
133 },
134 [key],
135 );
136
137 const serializedValue = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
138 const storedValue = useMemo(() => {
139 if (serializedValue === null) {
140 return initialValue;
141 }
142 try {
143 return decode(key, schema, serializedValue);
144 } catch (error) {
145 console.error("[LOCALSTORAGE] Could not decode stored value.", error);
146 return initialValue;
147 }
148 }, [initialValue, key, schema, serializedValue]);
149
150 const setValue = useCallback(
151 (value: T | ((val: T) => T)) => {
152 try {
153 let valueToStore: T;
154 if (typeof value === "function") {
155 // Like the read path, treat an unreadable stored value as the initial
156 // value, so a corrupt blob cannot block every later write.
157 let currentValue = initialValue;
158 try {
159 currentValue = getLocalStorageItem(key, schema) ?? initialValue;
160 } catch (error) {
161 console.error("[LOCALSTORAGE] Could not read stored value.", error);
162 }
163 try {
164 valueToStore = (value as (val: T) => T)(currentValue);
165 } catch (cause) {
166 throw new LocalStorageOperationError({
167 operation: "update",
168 storageKey: key,
169 cause,
170 });
171 }
172 } else {
173 valueToStore = value;
174 }
175 if (valueToStore === null) {
176 removeLocalStorageItem(key);
177 } else {
178 setLocalStorageItem(key, valueToStore, schema);
179 }
180 dispatchLocalStorageChange(key);
181 } catch (error) {
182 console.error("[LOCALSTORAGE] Could not update stored value.", error);
183 }
184 },
185 [initialValue, key, schema],
186 );
187
188 return [storedValue, setValue];
189}