React 2023- Auth y rutas privadas

React 2023- Auth y rutas privadas
Photo by Scott Webb / Unsplash

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:

React 2023 - Como setupear un proyecto de forma optima
Setupear proyecto de React con Typescript, vite, husky, eslint. Estructura de carpetas bien organizada. Buenas practicas

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.

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

💡
Pasamos un array vacío [] como segundo parámetro al useState para que solo se ejecute cuando se monta el componente.

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ón
  • saveCredentials() 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 del AuthContext y pasarle undefined
  • 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