1. Introduction
Vizend Dock is a front-end common module that manages user authentication information and current workspace information. It stores not only the access token and refresh token issued after login, but also the work contexts such as pavilion, cineroom, stage, and actor, and passes the necessary authentication headers and Dock context through interceptors during API requests. Therefore, if the state of the Dock is misaligned, it does not simply end with one part of the screen being displayed incorrectly. Login status, permission judgment, screen transitions, and API request contexts are all affected together.
The existing Dock was written with the assumption of a web application. Various atoms, hooks, and interceptors directly utilized browser APIs like window, localStorage, sessionStorage, and BroadcastChannel. While it worked when executed only on the web, this premise immediately became problematic when attempting to apply the Dock to mobile applications. This is because the React Native environment does not have the browser's window.localStorage and window.sessionStorage.
In the web environment, there were also issues where the previous user's Dock state remained, or the storage value changed, but the hook did not update immediately, reflecting changes only after a refresh. This was due to the authentication atom, Dock atom, and actual storage each having their state, resulting in only partial updates depending on the change path.
Due to the scheduling constraints for mobile application implementation, a long-term redesign was not possible. First, the web dependency was severed to allow for repository injection, and then the state reference point was consolidated into a single storage, after which the React hook was synchronized using the Observer pattern. This article explains the background and actual application process of the refactoring.
2. Issues Arising from the Existing Structure
2.1 Strong Coupling to Browser Storage
Before refactoring, the authentication status directly read from the browser storage at the moment the atom was declared. The Dock state also read values from window.sessionStorage at module loading to initialize the atom's value.
export const accessTokenAtom = atom(
sessionStorage.getItem(accessTokenKey) ?? '',
);
const load = () => {
const session =
window?.sessionStorage.getItem(dockSessionKey) ?? '{}';
const context =
window?.sessionStorage.getItem(dockContextKey) ?? '{}';
return {
session: JSON.parse(session),
context: JSON.parse(context),
};
};
This code does not separate storage technology and state management code. While the storage implementation is already provided in the browser, the application must choose which storage to use in React Native. To add mobile storage, all direct references scattered across atoms, hooks, and interceptors had to be found and modified. It was also not easy to inject a memory storage during testing.
The policies regarding storage locations were also dispersed across several areas. The rule for access tokens was to keep them in session storage, while refresh tokens and remembered values were placed in local storage, each implemented in atoms and interceptors respectively.
2.2 Dual Ownership of State by Atoms and Storage
In the existing structure, the Jotai atom was responsible for React's reactive state, while storage was responsible for persistent state. The problem was that not all changes necessarily passed through the atom. Hooks updated the atom, but interceptors could directly modify storage during the request process. Conversely, the value of the atom could change, while the timing of storage reflection or whether it was initialized could be different.
It was also difficult to resolve this issue solely with the storage events of the browser. Changes to storage executed in the same document do not trigger storage events for that document, and there are no browser events in mobile storage. Ultimately, an explicit connection was needed between the 'code that saves the value' and the 'code that re-renders the React component'.
-
After logging in or refreshing tokens, the storage value changed, but the hook retained the previous value.
-
When logging out, only the authentication token was deleted, and parts of the Dock context or logbook might remain, potentially leading the next user to perceive the previous state.
-
The order of atom initialization and interceptor initialization is different, which sometimes prevented the state from being read correctly on the first entry.
-
The source of the values read directly by the hook and the interceptor is different, making it difficult to reproduce the issue and debug.
2.3 Fragmentation of configuration responsibilities
On mobile, in addition to storage, it was necessary to replace web-specific actions. For instance, since window.alert and window.location cannot be called directly, alert and screen transition functions must be injected into the mobile application. However, in the initial structure, the authentication atom initialization, Dock atom initialization, auth interceptor storage, context interceptor storage, and logbook interceptor storage each had separate configuration functions.
If the configuration is divided into multiple entry points, there can be a mismatch where the hook sees the new storage while the interceptor sees the default storage. Therefore, not only the storage implementations but also the initialization boundaries needed to be consolidated.
3. First response: securing mobile execution paths with storage injection
First, I added a way to distinguish between web and non-web environments so that Dock code could run in React Native and external storage could be injected. The mobile application was made to pass the KeyValueStorage implementation while using the existing default storage on the web.
The common storage contract was defined as simple key-value operations for get, set, remove, and clear.
export interface KeyValueStorage {
get: (key: string) => string | null;
set: (key: string, value: string) => void;
remove: (key: string) => void;
clear: () => void;
}
Since Dock only depends on this interface, there is no need to know the specific storage technology. On the web, an implementation that wraps the browser storage is used, and on mobile, an adapter that satisfies the same contract is provided. In environments without a separate session storage, a single storage is configured to serve both roles.
During the initial implementation process, I checked the logs of the passed storage object and the actual storage and retrieval results. In the mobile build, the timing of the module loading and the timing of the storage injection were different, making it important to verify the initialization order.
In addition, to replace web-specific notifications and transitions, I made it so that showAlert and navigateTo handlers are injected. This pushed not only the storage but also platform-dependent side effects outside.
This first response secured the mobile execution path, but the atom and storage still managed the state simultaneously, and the initialization functions of authentication and Dock were also separate. Simply enabling storage injection did not resolve the state synchronization issue.
4. Final direction: transitioning storage to a single reference point
After the first response, I shifted from hiding the storage behind atoms to making the storage itself the reference point for the state. The core principles were fourfold.
First, the persistent state of authentication and Dock is only read and written through a central storage manager.
Second, React hooks reflect values read from the manager without owning them separately.
Third, when a value is changed in the manager, it immediately notifies the subscribed hooks for that key.
Fourth, storage and interceptor initialization is performed from a single entry point.
We removed the existing chain atom structure and introduced CentralStorageManager. We did not eliminate Jotai from the entire Dock. Only states that require persistence, such as authentication tokens and Dock session/context, were moved to the storage manager, while the existing method was maintained for states that are only needed in memory, such as heartbeat and development mode.
The purpose was not to unify all states with a single technology, but to eliminate the problem of the original of persistent states existing in both atom and storage.
5. Implementation of Storage Abstraction
5.1 Central Manager and Storage Location Policy
CentralStorageManager receives the implementation of local/session storage once and provides common get, set, and remove operations. Values other than strings are serialized into JSON, and JSON parsing is attempted first during retrieval.
export class CentralStorageManager {
private localStorage: KeyValueStorage | null = null;
private sessionStorage: KeyValueStorage | null = null;
private listeners = new Map<string, Set<(value: any) => void>>();
initialize(
localStorage: KeyValueStorage,
sessionStorage: KeyValueStorage,
) {
this.localStorage = localStorage;
this.sessionStorage = sessionStorage;
}
set<T>(
key: string,
value: T,
storageType: StorageType = 'session',
): void {
const storage = this.getStorage(storageType);
if (!storage) return;
const serialized =
typeof value === 'string' ? value : JSON.stringify(value);
storage.set(key, serialized);
this.notifyListeners(key, value);
}
remove(
key: string,
storageType: StorageType = 'session',
): void {
const storage = this.getStorage(storageType);
if (!storage) return;
storage.remove(key);
this.notifyListeners(key, undefined);
}
}
The storage location policy has been consolidated within the auth and dock domain APIs. The caller does not have to determine which storage the access token is stored in each time and simply calls storageManager.auth.setAccessToken(). The refresh token is local, the access token is session, and the policies for Dock session and context are reflected in a single file.
auth = {
getAccessToken: () =>
this.get(STORAGE_KEYS.ACCESS_TOKEN, '', 'session'),
setAccessToken: (token: string) =>
this.set(STORAGE_KEYS.ACCESS_TOKEN, token, 'session'),
getRefreshToken: () =>
this.get(STORAGE_KEYS.REFRESH_TOKEN, '', 'local'),
setRefreshToken: (token: string) =>
this.set(STORAGE_KEYS.REFRESH_TOKEN, token, 'local'),
clearTokens: () => {
this.remove(STORAGE_KEYS.ACCESS_TOKEN, 'session');
this.remove(STORAGE_KEYS.REFRESH_TOKEN, 'local');
this.remove(STORAGE_KEYS.REMEMBERED, 'local');
},
};
Since the same API is used in hooks, interceptors, logout handling, and token refreshing, only the central manager needs to be modified even if the storage location changes. In testing, a memory-based KeyValueStorage can be injected to construct the state flow without a browser.
5.2 API for Partially Updating Dock State
The existing dockAtom received a large object that combined the entire session and context. Even if only some fields needed to be changed, the current object had to be retrieved, copied, and saved back to both atom and storage. After refactoring, the manager provided a function for partial updates after checking the current value.
updateContext: (updates: Partial<DockContext>): void => {
const currentContext = this.dock.getContext();
const updatedContext = { ...currentContext, ...updates };
this.set(
STORAGE_KEYS.DOCK_CONTEXT,
updatedContext,
'session',
);
},
updateSessionField: <T extends keyof SessionDockRdo>(
field: T,
value: SessionDockRdo[T],
): void => {
const currentSession = this.dock.getSession();
const updatedSession = {
...currentSession,
[field]: value,
};
this.set(
STORAGE_KEYS.DOCK_SESSION,
updatedSession,
'session',
);
},
Subsequently, useDock began to use an API that reveals the intent of changes rather than replacing the entire large atom object, and all writes now go through the same notification path.
6. Synchronizing hooks and storage with Observer pattern
Making storage a single reference point creates the next problem. Since storage is not a React state, components do not automatically re-render when the value changes. To address this, we added a key-based subscription feature to the manager.
subscribe(
key: string,
listener: (value: any) => void,
): () => void {
if (!this.listeners.has(key)) {
this.listeners.set(key, new Set());
}
this.listeners.get(key)!.add(listener);
return () => {
const keyListeners = this.listeners.get(key);
keyListeners?.delete(listener);
if (keyListeners?.size === 0) {
this.listeners.delete(key);
}
};
}
private notifyListeners(key: string, value: any): void {
this.listeners.get(key)?.forEach((listener) => {
listener(value);
});
}
The manager acts as the Subject while the hook acts as the Observer. When set or remove is executed, it notifies the listener for the corresponding key, and the listener re-executes the getter to also calculate derived values like authenticated. This subscription logic has been separated into a common hook called useStorageValues.
export const useStorageValues = <
T extends Record<string, any>
>(
gettersMap: { [K in keyof T]: () => T[K] },
subscriptionKeys: string[],
): T => {
const gettersMapRef = useRef(gettersMap);
gettersMapRef.current = gettersMap;
const [values, setValues] = useState<T>(() => {
const initialValues = {} as T;
for (const [key, getter] of Object.entries(gettersMap)) {
initialValues[key as keyof T] = getter();
}
return initialValues;
});
const updateValues = useCallback(() => {
const newValues = {} as T;
for (
const [key, getter]
of Object.entries(gettersMapRef.current)
) {
newValues[key as keyof T] = getter();
}
setValues(newValues);
}, []);
useEffect(() => {
if (!storageManager.isInitialized()) return;
const unsubscribers = subscriptionKeys.map((key) =>
storageManager.subscribe(key, updateValues),
);
updateValues();
return () =>
unsubscribers.forEach((unsubscribe) => unsubscribe());
}, [updateValues]);
return values;
};
getterMap is maintained as a ref to ensure that the listener function does not change every time it renders. useAuthValues subscribes to authentication-related keys, while useDockValues subscribes to Dock session and context. While extracting this as a common hook, the forced refresh counter that existed in each hook and the redundant subscription/unsubscription code were also removed.
7. Consolidation of Initialization Boundaries
To operate the storage manager, each platform must inject the correct implementation. For this, the initialization of storage and the configuration of interceptors have been consolidated into initStorageSystem.
On the web, if there are no separate settings, the existing local/session storage adapter is used. In non-web environments, the storage along with showAlert and navigateTo handlers are required, and if sessionStorage is not available, the local storage implementation is used. Then, the storage manager is initialized first, and interceptors like auth, context, and log are configured.
if (isWeb) {
finalLocalStorage =
localStorage || getLocalKeyValueStorage();
finalSessionStorage =
sessionStorage || getSessionKeyValueStorage();
} else {
setNotWebHandlers({
showAlert: notWebHandlers!.showAlert,
navigateTo: notWebHandlers!.navigateTo,
});
finalLocalStorage = localStorage!;
finalSessionStorage = sessionStorage || localStorage!;
}
storageManager.initialize(
finalLocalStorage,
finalSessionStorage,
);
configureInterceptors([
authInterceptor,
contextInterceptor,
logbookInterceptor,
...interceptors,
]);
The initialization function also includes checks to prevent duplicate calls. Once set at the application entry point, hooks and interceptors use the same storage manager. The existing calling method on the web is retained to minimize changes to existing applications.
8. Results of Implementation
The biggest outcome of this work is that web functionalities can now be reused on mobile and that the flow of state changes can be described as a single path.
First, the storage implementation has been separated from Dock. The web uses the default adapter, while mobile injects an implementation that satisfies the KeyValueStorage contract.
Second, the baseline states of authentication tokens and Dock session/context have been consolidated into the storage manager. The problem of hooks and interceptors viewing different state sources has been reduced, and the screen is now immediately updated through Observer notifications after value changes.
Third, the responsibilities for storage policy and initialization have been centralized. The locations for storing access/refresh tokens, the order of web/non-web initialization, and the cleanup scope during logout can all be verified from a limited file. During logout and session expiration, not only the token but also the Dock session, context, and logs are deleted to prevent residual issues with the previous user's state.
Fourth, the file that managed the chain atoms has been removed, and redundancy has decreased while restructuring roles with storageManager and useStorageSubscription.
Fifth, useCrossTabTokenRefresh, useLogbook, and auth/context/log interceptors now use the same manager API, making subsequent modification points clearer.
This is not a task where quantitative performance metrics were measured separately. However, it has secured a structure that eliminates the causes of residual state during user transitions and allows for initialization on mobile without web storage, while also removing reliance on refresh for state update paths.
9. Lessons Learned
The biggest lesson learned from this task is that ownership of state is more important than the state management library itself. It wasn't complex because we used a lot of atoms, but rather because atoms, storage, and interceptors all owned the state and could modify it in different ways. Whenever there are two or more original states, the synchronization code continues to increase regardless of the technology used.
The second point is that the scope of platform abstraction needs to be appropriately defined. Initially, it seemed like an issue of changing localStorage to mobile storage, but platform assumptions had also spread to notifications, transitions, initialization order, token refresh, and logbook. We needed to separate not only the storage interface but also initialization boundaries and side effects.
The third point is that the Observer pattern can serve as a practical binding mechanism. By allowing the entity that records the state to directly notify changes, we could use the same responsive update method on both web and mobile, and also standardize the forced rendering code by hooks.
The fourth point is that distinguishing between urgent primary fixes and structural improvements is a realistic strategy. We first secured a feasible path and identified actual failure points, and then progressively eliminated atom chain structures, introduced a central manager, separated subscription hooks, and integrated initialization.
10. Conclusion
The mobile application of Vizend Dock was not just a matter of wrapping the browser API with conditional statements. The issues of state duplication and initialization fragmentation, which had been latent before, became more evident during the platform expansion process.
We were able to simplify the state flow by abstracting the storage as KeyValueStorage, establishing the central manager as a single reference point for persistent state, and linking hooks via a key-based Observer pattern. We created a structure that could inject mobile storage while maintaining the existing usage of the web by integrating the initialization entry point and logout cleanup scope.
This experience showed that an important question in frontend state management refactoring is not just “which library to choose.” What needs to be clarified first is where the original state is, who can change it, and how changes are communicated to consumers. Once these three are clearly defined, we can create an extensible structure even if the library and platform differ.
IAN