React 2023- Auth y rutas privadas
Introducción
Muchas de las apps que desarrollamos en frontend suelen tener vistas que solo pueden ser accedidas por usuarios logueados. A estas vistas se les conoce como Rutas Privadas (private routes) y hoy les voy a mostrar cómo implementarlas de una forma súper sencilla.
1. Setup del proyecto
Voy a estar utilizando el setup que armamos en mi primer artículo. Por si no lo vieron, les recomiendo que lo lean por arriba para entender mejor la solución:
Para manejar las rutas en este ejemplo, vamos a utilizar react-router-dom, la librería más utilizada para routing en React. La estructura de carpeta que suelo utilizar para las rutas es la siguiente:
src/components/routes/index.tsx
- Aquí exporto mi componente global de rutas.
src/components/routes/links.tsx
- Aquí guardo uno o varios
enum
que contienen los path de cada ruta.
- Aquí guardo uno o varios
Estos archivos se ven así:
import { PublicRoutes } from './PublicRoutes'
import { PrivateRoutes } from './PrivateRoutes'
export const AppRoutes = () => (
<>
<PublicRoutes />
<PrivateRoutes />
</>
)
src/components/routes/index.tsx
export enum ELinks {
login = '/',
adminPanel = '/admin'
}
src/components/routes/links.tsx
Ahora, dentro de mi carpeta src/components/views
suelo separar en dos carpetas; PublicRoutes
y PrivateRoutes
.

En la carpeta PublicRoutes
van todas las vistas a las que se pueden acceder sin necesidad de estar logueado, mientras que las que están en la carpeta PrivateRoutes
requieren estar logueado (y en algunos casos más complejos, hasta es necesario tener ciertos roles).
En ambas carpetas hay un archivo index.tsx
que es donde se exportan todas las rutas de cada tipo y suele verse así:
import { Switch, Route } from 'react-router-dom'
import Login from '../views/PublicRoutes/Login'
import { ELinks } from './links'
export const PublicRoutes = () => (
<Switch>
<Route exact path={ELinks.login}>
<Login />
</Route>
</Switch>
)
src/components/views/PublicRoutes/index.tsx
2. Componente PrivateRoute
Para las rutas públicas es bastante sencillo: simplemente ponemos la ruta y el componente que queremos renderizar, pero... ¿Cómo hacemos con las rutas privadas? Hasta donde yo sé, react-router-dom no viene con un componente nativo para manejar esto; por suerte, es bastante sencillo crear uno:
import { Redirect, Route, RouteProps } from 'react-router-dom'
import { ELinks } from '../routes/links'
interface Props extends RouteProps {
component: any
isAuth: boolean
}
export const PrivateRoute = ({
component: Component,
isAuth,
...rest
}: Props) => (
<Route
{...rest}
render={(props) => (
isAuth
? <Component {...props} />
: <Redirect to={ELinks.login} />
)}
/>
)
src/components/shared/PrivateRoute.tsx
Básicamente, creamos un componente que tome como props el componente de la vista que queremos renderizar en caso de que "isAuth" sea true (al cual le ponemos un alias en mayúscula para respetar las reglas de React). Caso contrario, queremos que nos redireccione a la vista de inicio. Veamos cómo usar este componente:
import { Switch } from 'react-router-dom'
import { ELinks } from '../../routes/links'
import { AdminDashboard } from './AdminDashboard'
import { PrivateRoute } from '../../shared/PrivateRoute'
export const PrivateRoutes = () => (
<Switch>
<PrivateRoute
component={AdminDashboard}
path={ELinks.adminDashboard}
isAuth={true}
/>
</Switch>
)
src/components/views/PrivateRoutes/index.tsx
3. Login
Antes de continuar, vamos a armar un simple Login que nos permita guardar las credenciales de usuario en el localStorage. Dado que el objetivo es mostrar cómo manejar auth y rutas privadas, voy a obviar las validaciones y las llamadas a la API.
import { useState } from 'react'
import {
Box,
Button,
Container,
Stack,
TextField,
} from '@mui/material'
import { useHistory } from 'react-router-dom'
import { LoginDto } from '../../../validations/basic/auth.dto'
import AuthService from '../../../services/basics/auth.service'
import { ELinks } from '../../routes/links'
export const Login = () => {
const history = useHistory()
const [data, setData] = useState<LoginDto>({
email: '',
password: '',
})
const handleSubmit = async () => {
try {
const res = await AuthService.login(data)
localStorage.setItem('credentials', JSON.stringify(res.data))
history.push(ELinks.adminDashboard)
} catch (error) {
console.log(error)
}
}
return (
<Box sx={{
width: '100%',
height: '100%'
}}>
<Container maxWidth='md'>
<Stack gap={2}>
<TextField
value={data.email}
label='email'
onChange={(e) => setData((x) => ({ ...x, email: e.target.value }))}
/>
<TextField
value={data.password}
type='password'
label='password'
onChange={(e) => setData((x) => ({ ...x, password: e.target.value }))}
/>
<Button onClick={handleSubmit}>Login</Button>
</Stack>
</Container>
</Box>
)
}
src/components/views/PublicRoutes/Login.tsx
4. Manejando el state de auth
Ahora, lo que nos falta es reemplazar el isAuth={true}
de PrivateRoute
por algo dinámico que nos indique en realidad si estamos logueados o no. ¿Cómo podemos hacer esto? Podríamos usar el usuario que se guarda en el localStorage en el login y verificar este mismo cuando se monta el componente.
import { Switch } from 'react-router-dom'
import { useMemo } from 'react'
import { ELinks } from '../../routes/links'
import { AdminDashboard } from './AdminDashboard'
import { PrivateRoute } from '../../shared/PrivateRoute'
import { ICredentials } from '../../../types/types'
export const PrivateRoutes = () => {
const user = useMemo(() => {
const credentialsJson = localStorage.getItem('credentials')
const credentials: ICredentials | null = credentialsJson
? JSON.parse(credentialsJson)
: null
return credentials
}, [])
return (
<Switch>
<PrivateRoute
component={AdminDashboard}
path={ELinks.adminDashboard}
isAuth={!!user}
/>
</Switch>
)
}
src/components/views/PrivateRoutes/index.tsx
El problema con esta solución es que no estamos escuchando cambios en el localStorage, por lo que si en algún momento las credenciales se borran (ej.: el usuario cerró sesión), nuestra vista seguiría igual. Nuestro objetivo es crear una solución reactiva que nos indique en "tiempo real" si el usuario está logueado.
Implementando React context
Una mejor solución para esto es utilizar React.Context con useContext para poder suscribirnos a los cambios de este. Veamos cómo implementarlo:
import React, { Dispatch, SetStateAction } from 'react'
import { ICredentials } from '../../types/types'
export interface IAuthContext {
credentials?: ICredentials
setCredentials: Dispatch<SetStateAction<ICredentials | undefined>>
}
const initialContext: IAuthContext = {
setCredentials: () => { }
}
export const AuthContext = React.createContext<IAuthContext>(initialContext)
const AuthContextProvider = AuthContext.Provider
export default AuthContextProvider
src/components/context/auth.context.ts
Dentro de la carpeta src/components/context
creamos un archivo auth.context.ts
donde definimos los siguientes elementos:
- Una
interface
que defina el objeto que va a devolver nuestro context - Un
initialContext
que va definir el estado inicial de nuestro context - El objecto
AuthContext
(nuestro context)- Para simplificar, exportamos solo el Provider de dicho context
React context funciona de la siguiente forma: nos devuelve un provider con el cual todos los componentes hijos de dicho provider tienen acceso a los datos que expone el mismo. Pueden acceder a dichos datos a través del hook useContext
.
Entonces, ¿dónde colocamos nuestro provider? Bueno, en la mayoría de las apps que he desarrollado, basta con colocarlo en el componente más alto de nuestra app, en App.tsx
.
import { StyledEngineProvider, ThemeProvider } from '@mui/material'
import { QueryClient, QueryClientProvider } from 'react-query'
import { useState } from 'react'
import { AppRoutes } from './components/routes'
import { theme } from './styles/theme'
import AuthContextProvider from './components/context/auth.context'
import { ICredentials } from './types/types'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 2 * (1000 * 60) // 2 min
}
}
})
const App = () => {
const [credentials, setCredentials] = useState<ICredentials | undefined>(undefined)
return (
<QueryClientProvider client={queryClient}>
<StyledEngineProvider injectFirst>
<ThemeProvider theme={theme}>
<AuthContextProvider value={{ credentials, setCredentials }}>
<AppRoutes />
</AuthContextProvider>
</ThemeProvider>
</StyledEngineProvider>
</QueryClientProvider>
)
}
export default App
src/App.tsx
Básicamente, lo que estamos haciendo es pasar una variable de estado y una función para actualizar dicha variable (la cual creamos con useState
) a lo largo de toda nuestra app.
¿Qué tal si abstraemos este código en un Wrapper/Provider (como le guste llamarlo) para dejar el App.tsx
lo más limpio posible?
import React, { useState } from 'react'
import AuthContextProvider from '../context/auth.context'
import { ICredentials } from '../../types/types'
interface Props {
children: any
}
export const AuthProvider = ({ children }: Props) => {
const [credentials, setCredentials] = useState<ICredentials | undefined>(undefined)
return (
<AuthContextProvider value={{ credentials, setCredentials }}>
{children}
</AuthContextProvider>
)
}
src/components/provider/auth.provider.tsx
Y así nos quedaría el App.tsx
import { StyledEngineProvider, ThemeProvider } from '@mui/material'
import { QueryClient, QueryClientProvider } from 'react-query'
import { AppRoutes } from './components/routes'
import { theme } from './styles/theme'
import { AuthProvider } from './components/providers/auth.provider.tsx'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 2 * (1000 * 60) // 2 min
}
}
})
const App = () => (
<QueryClientProvider client={queryClient}>
<StyledEngineProvider injectFirst>
<ThemeProvider theme={theme}>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</ThemeProvider>
</StyledEngineProvider>
</QueryClientProvider>
)
export default App
src/App.tsx
Ahora volviendo a nuestro ejemplo inicial, podemos consumir el user directamente:
import { Switch } from 'react-router-dom'
import { useContext } from 'react'
import { ELinks } from '../../routes/links'
import { AdminDashboard } from './AdminDashboard'
import { PrivateRoute } from '../../shared/PrivateRoute'
import { AuthContext } from '../../context/auth.context'
export const PrivateRoutes = () => {
const { credentials } = useContext(AuthContext)
return (
<Switch>
<PrivateRoute
component={AdminDashboard}
path={ELinks.adminDashboard}
isAuth={!!credentials}
/>
</Switch>
)
}
src/components/views/PrivateRoutes/index.tsx
Y así, cuando llamemos a la función setCredentials
(que nos devuelve nuestro AuthContext
) para cerrar sesión (pasándole undefined
), nuestra variable credentials se va a actualizar y, por ende, isAuth = false
y nos va a redireccionar a inicio /
. A continuación, un ejemplo de cómo llamarlo.
import { useContext } from 'react'
import { Box, Button } from '@mui/material'
import { AuthContext } from '../../../context/auth.context'
export const AdminDashboard = () => {
const { setCredentials } = useContext(AuthContext)
/**
* logica del componente
*/
const handleLogout = () => {
setCredentials(undefined)
localStorage.remove('credentials')
}
return (
<Box>
{/** Logica del componente */}
<Button onClick={handleLogout}>Cerrar sesion</Button>
{/** Logica del componente */}
</Box>
)
}
src/components/views/PrivateRoutes/AdminDashboard/index.tsx
5. Cargar credenciales al abrir la app
Dado que el localStorage nos persiste los datos incluso cuando la app se cierra, estaría bueno que al abrir la app ya estemos logueados (que nuestro AuthContext
tenga esos datos). Esto es muy simple de hacer con un useEffect
.
import React, { useEffect, useState } from 'react'
import AuthContextProvider from '../context/auth.context'
import { ICredentials } from '../../types/types'
interface Props {
children: any
}
export const AuthProvider = ({ children }: Props) => {
const [credentials, setCredentials] = useState<ICredentials | undefined>(undefined)
useEffect(() => {
const credentialsJson = localStorage.getItem('credentials')
const credentials = credentialsJson ? JSON.parse(credentialsJson) : null
if (credentials) {
/**
* logica para verificar que las credentiales sigan siendo validas
*/
setCredentials(credentials)
}
}, [])
return (
<AuthContextProvider value={{ credentials, setCredentials }}>
{children}
</AuthContextProvider>
)
}
src/components/providers/auth.provider.ts
6. Mejorando la solucion
Todavia podemos hacerles unas mejoras a nuestra solución:
- Abstraer las llamadas al localStorage en una función que maneje los posibles errores.
- Crear nuestro propio Hook useAuth()
Mejorar las llamadas al local storage
Empecemos con la primera, vamos a crear un helper credentials.helper.ts
con 3 funciones:
import { ICredentials } from '../types/types'
const key = 'credentials-admin'
export const setCredentialsLocalStorage = (data: ICredentials): void => {
localStorage.setItem(key, JSON.stringify(data))
}
export const getCredentialsLocalStorage = (): ICredentials | undefined => {
try {
const stringified = localStorage.getItem(key)
let credentials: ICredentials | undefined
if (stringified) credentials = JSON.parse(stringified)
if (!credentials) return undefined
return credentials
} catch (error) {
console.log(error)
return undefined
}
}
export const clearCredentialsLocalStorage = (): void => {
localStorage.removeItem(key)
}
src/utils/credentials.helper.ts
De esta forma, reducimos el código repetido y el riesgo de llamar mal al localStorage intentando acceder a una key errónea.
Así nos quedaría nuestro auth.provider.ts
export const AuthProvider = ({ children }: Props) => {
const [credentials, setCredentials] = useState<ICredentials | undefined>(undefined)
useEffect(() => {
const credentials = getCredentialsLocalStorage()
if (credentials) {
/**
* logica para verificar que las credentiales sigan siendo validas
*/
setCredentials(credentials)
}
}, [])
return (
<AuthContextProvider value={{ credentials, setCredentials }}>
{children}
</AuthContextProvider>
)
}
src/components/provider/auth.provider.tsx
Crear el hook useAuth()
Ahora bien, estaría bueno si pudiéramos tener un hook que nos oculte los detalles y nos exponga funciones útiles para nuestra app:
logout()
una función para cerrar sesiónsaveCredentials()
una función que llamaremos cada vez que iniciamos sesión
import { useContext } from 'react'
import { AuthContext } from '../context/auth.context'
import { ICredentials } from '../../types/types'
import { clearCredentials, setCredentialsLocalStorage } from '../../utils/credentials.helper'
export const useAuth = () => {
const { setCredentials, credentials } = useContext(AuthContext)
const logout = () => {
setCredentials(undefined)
clearCredentials()
}
const saveCredentials = (obj: ICredentials) => {
setCredentials(obj)
setCredentialsLocalStorage(obj)
}
return { credentials, logout, saveCredentials }
}
src/componentes/hooks/use-auth.ts
Utilizando las funciones de localStorage
que definimos antes, el código queda bien sencillo y limpio.
Cuál es el propósito de hacer esto?
Consideremos el caso de cerrar sesión
, para hacerlo debemos:
- Obtener el context correcto con
useContext
- Llamar a la función
setCredentials
delAuthContext
y pasarleundefined
- Borrar las credenciales en el localStorage
Al usar el hook, reducimos la posibilidad de introducir errores en la app, ya que tenemos que preocuparnos por menos detalles, y las responsabilidades de dichas tareas recaen sobre el hook en sí.
import { useState } from 'react'
import {
Box,
Button,
Container,
Stack,
TextField,
} from '@mui/material'
import { useHistory } from 'react-router-dom'
import { LoginDto } from '../../../validations/basic/auth.dto'
import AuthService from '../../../services/basics/auth.service'
import { ELinks } from '../../routes/links'
import { useAuth } from '../../hooks/use-auth'
export const Login = () => {
const history = useHistory()
const { saveCredentials } = useAuth()
const [data, setData] = useState<LoginDto>({
email: '',
password: '',
})
const handleSubmit = async () => {
try {
const res = await AuthService.login(data)
saveCredentials(res.data) // actualizamos esta linea
history.push(ELinks.adminDashboard)
} catch (error) {
console.log(error)
}
}
return (
<Box sx={{
width: '100%',
height: '100%'
}}>
<Container maxWidth='md'>
<Stack gap={2}>
<TextField
value={data.email}
label='email'
onChange={(e) => setData((x) => ({ ...x, email: e.target.value }))}
/>
<TextField
value={data.password}
type='password'
label='password'
onChange={(e) => setData((x) => ({ ...x, password: e.target.value }))}
/>
<Button onClick={handleSubmit}>Login</Button>
</Stack>
</Container>
</Box>
)
}
src/components/views/PublicRoutes/Login.tsx
Y así quedaría el AdminDashboard
con el uso de useAuth()
import { Box, Button } from '@mui/material'
import { useAuth } from '../../../hooks/use-auth'
export const AdminDashboard = () => {
const { logout } = useAuth()
/**
* logica del componente
*/
return (
<Box>
{/** Logica del componente */}
<Button onClick={logout}>Cerrar sesion</Button>
{/** Logica del componente */}
</Box>
)
}
src/componentes/views/PrivateRoutes/AdminDashboard/index.tsx
Conclusion
Hemos visto cómo utilizar rutas privadas en react-router-dom y cómo manejar autenticación en nuestra app de una forma limpia y escalable. Si bien el ejemplo fue simple, este es el enfoque que suelo utilizar para la mayoría de los proyectos y me resulta muy sencillo hacer cambios cuando lo necesito.
Gracias por leer :D