Logout в приложении — это не только удаление токена авторизации. В большинстве случаев необходимо полностью очистить клиентское состояние, связанное с пользователем:
Если очистка выполнена неполностью, приложение может продолжать отображать приватные данные предыдущего пользователя, использовать старые токены или автоматически отправлять устаревшие запросы.
Типичная последовательность logout:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const authApi = createApi({
reducerPath: 'authApi',
baseQuery: fetchBaseQuery({
baseUrl: '/api',
}),
endpoints: (builder) => ({
logout: builder.mutation({
query: () => ({
url: '/logout',
method: 'POST',
}),
}),
}),
});
export const {
useLogoutMutation,
} = authApi;
После logout access token больше не должен использоваться.
Пример хранения токенов:
localStorage.setItem('accessToken', token);
Очистка:
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
Если токены хранятся в cookies:
document.cookie =
'accessToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
Часто в Redux Store хранится информация о текущем пользователе:
{
auth: {
user: {
id: 1,
name: 'Alex'
}
}
}
После logout эти данные должны быть удалены.
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
user: null,
isAuthenticated: false,
};
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
setCredentials(state, action) {
state.user = action.payload;
state.isAuthenticated = true;
},
logout(state) {
state.user = null;
state.isAuthenticated = false;
},
},
});
export const {
setCredentials,
logout,
} = authSlice.actions;
export default authSlice.reducer;
RTK Query хранит кэш запросов внутри api slice.
Даже после удаления токена старые данные могут оставаться в store:
{
queries: {
getProfile: {
data: {
email: 'admin@test.com'
}
}
}
}
Если cache не очистить:
RTK Query предоставляет встроенный action:
api.util.resetApiState()
Он полностью очищает:
import { store } from './store';
import { authApi } from './services/authApi';
import { logout } from './features/auth/authSlice';
export const performLogout = () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
store.dispatch(logout());
store.dispatch(authApi.util.resetApiState());
};
Часто logout инкапсулируют в отдельный hook.
import { useDispatch } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { authApi } from '../services/authApi';
import { logout } from '../features/auth/authSlice';
export const useLogout = () => {
const dispatch = useDispatch();
const navigate = useNavigate();
const handleLogout = () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
dispatch(logout());
dispatch(authApi.util.resetApiState());
navigate('/login');
};
return handleLogout;
};
Многие backend-приложения требуют уведомить сервер о logout:
logout: builder.mutation({
query: () => ({
url: '/auth/logout',
method: 'POST',
}),
})
Использование:
const [logoutRequest] = useLogoutMutation();
const handleLogout = async () => {
try {
await logoutRequest().unwrap();
} catch (error) {
console.error(error);
}
localStorage.removeItem('accessToken');
dispatch(logout());
dispatch(api.util.resetApiState());
};
Сервер может быть недоступен:
500 Internal Server Error
Но пользователь всё равно должен выйти из системы.
Неправильно:
try {
await logoutRequest().unwrap();
clearState();
} catch {
console.error('Logout failed');
}
Если запрос упал — logout не выполнится.
Правильный вариант:
try {
await logoutRequest().unwrap();
} catch (error) {
console.error(error);
}
clearState();
Удобно выносить очистку в отдельную функцию.
export const clearState = (dispatch) => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
dispatch(logout());
dispatch(api.util.resetApiState());
};
Очень распространённый сценарий — автоматический logout при невалидном refresh token.
const baseQuery = fetchBaseQuery({
baseUrl: '/api',
prepareHeaders: (headers) => {
const token = localStorage.getItem('accessToken');
if (token) {
headers.set('authorization', `Bearer ${token}`);
}
return headers;
},
});
const baseQueryWithReauth = async (
args,
api,
extraOptions
) => {
let result = await baseQuery(
args,
api,
extraOptions
);
if (result.error?.status === 401) {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
api.dispatch(logout());
api.dispatch(authApi.util.resetApiState());
}
return result;
};
После logout некоторые компоненты могут автоматически перезапускать запросы.
Например:
const { data } = useGetProfileQuery();
Компонент остаётся смонтированным и снова отправляет запрос.
const isAuthenticated = useSelector(
state => state.auth.isAuthenticated
);
const { data } = useGetProfileQuery(undefined, {
skip: !isAuthenticated,
});
Если используется redux-persist, данные могут автоматически восстановиться после refresh страницы.
import { persistStore } from 'redux-persist';
export const persistor = persistStore(store);
await persistor.purge();
Полный logout:
export const performLogout = async () => {
localStorage.removeItem('accessToken');
store.dispatch(logout());
store.dispatch(api.util.resetApiState());
await persistor.purge();
};
RTK Query часто используется вместе с websocket.
После logout соединение необходимо закрывать.
socket.close();
RTK Query поддерживает polling:
useGetNotificationsQuery(undefined, {
pollingInterval: 5000,
});
После logout polling должен прекратиться.
resetApiState() автоматически очищает polling
subscriptions.
Некоторые разработчики пытаются очищать cache через invalidatesTags:
invalidatesTags: ['User']
Это не полноценный logout.
invalidateTags:
Для logout нужен именно:
api.util.resetApiState()
export const performLogout = async (
dispatch,
navigate
) => {
try {
await dispatch(
authApi.endpoints.logout.initiate()
).unwrap();
} catch (error) {
console.error(error);
}
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
dispatch(logout());
dispatch(authApi.util.resetApiState());
navigate('/login');
};
В больших приложениях может быть несколько API:
userApi
adminApi
chatApi
billingApi
Каждый хранит собственный cache.
dispatch(userApi.util.resetApiState());
dispatch(adminApi.util.resetApiState());
dispatch(chatApi.util.resetApiState());
dispatch(billingApi.util.resetApiState());
export const logoutService = async ({
dispatch,
navigate,
}) => {
localStorage.clear();
dispatch(authSlice.actions.logout());
dispatch(userApi.util.resetApiState());
dispatch(chatApi.util.resetApiState());
dispatch(adminApi.util.resetApiState());
navigate('/login');
};
Возможна ситуация:
Обычно используют:
RTK Query поддерживает abort.
const promise = dispatch(
api.endpoints.getProfile.initiate()
);
promise.abort();
При SSR нельзя использовать:
localStorage
window
document
Нужна проверка:
if (typeof window !== 'undefined') {
localStorage.removeItem('accessToken');
}
Современные backend-системы часто используют rotation refresh token.
При logout сервер:
Frontend в этом случае обязан:
Неполный logout создаёт серьёзные риски:
На production-проектах logout обычно включает:
localStorage.removeItem('accessToken');
dispatch(logout());
dispatch(api.util.resetApiState());
export const performLogout = async ({
dispatch,
navigate,
persistor,
socket,
}) => {
try {
await dispatch(
authApi.endpoints.logout.initiate()
).unwrap();
} catch (error) {
console.error(error);
}
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
socket?.close();
dispatch(logout());
dispatch(authApi.util.resetApiState());
await persistor.purge();
navigate('/login');
};