c
Comunicarse con el servidor en una aplicación redux
Expandamos la aplicación, de modo que las notas se almacenen en el backend. Usaremos json-server, de la parte 2.
El estado inicial de la base de datos se almacena en el archivo db.json, que se coloca en la raíz del proyecto:
{
"notes": [
{
"content": "the app state is in redux store",
"important": true,
"id": 1
},
{
"content": "state changes are made with actions",
"important": false,
"id": 2
}
]
}Instalaremos json-server en nuestro proyecto...
npm install json-server --save-devy agregaremos la siguiente línea a la parte de scripts del archivo package.json
"scripts": {
"server": "json-server -p 3001 db.json",
// ...
}Ahora iniciemos json-server con el comando npm run server.
Fetch API
En el desarrollo de software, a menudo es necesario considerar si una cierta funcionalidad debe implementarse usando una librería externa o si es mejor utilizar las soluciones nativas proporcionadas por el entorno. Ambos enfoques tienen sus propias ventajas y desafíos.
En las partes anteriores de este curso, usamos la librería Axios para hacer peticiones HTTP. Ahora, exploremos una forma alternativa de hacer peticiones HTTP usando la Fetch API nativa.
Es típico que una librería externa como Axios se implemente usando otras librerías externas. Por ejemplo, si instalas Axios en tu proyecto con el comando npm install axios, la salida de la consola será:
$ npm install axios
added 23 packages, and audited 302 packages in 1s
71 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilitiesPor lo tanto, además de la librería Axios, el comando instalaría más de 20 paquetes npm adicionales que Axios necesita para funcionar.
La Fetch API proporciona una forma similar de hacer peticiones HTTP como Axios, pero usar la Fetch API no requiere instalar ninguna librería externa. El mantenimiento de la aplicación se vuelve más fácil cuando hay menos librerías que actualizar, y la seguridad también mejora porque la superficie de ataque potencial de la aplicación se reduce. La seguridad y el mantenimiento de las aplicaciones se discute más a fondo en la parte 7 del curso.
En la práctica, las peticiones se realizan usando la función fetch(). La sintaxis utilizada difiere algo de Axios. También notaremos pronto que Axios se ha encargado de algunas cosas por nosotros y nos ha facilitado la vida. Sin embargo, ahora usaremos la Fetch API, ya que es una solución nativa ampliamente utilizada que todo desarrollador Full Stack debería conocer.
Obteniendo datos del backend
Creemos un método para obtener datos del backend en el archivo src/services/notes.js:
const baseUrl = 'http://localhost:3001/notes'
const getAll = async () => {
const response = await fetch(baseUrl)
if (!response.ok) {
throw new Error('Failed to fetch notes')
}
const data = await response.json()
return data
}
export default { getAll }Examinemos más de cerca la implementación del método getAll. Las notas ahora se obtienen del backend llamando a la función fetch(), a la cual se le da la URL del backend como argumento. El tipo de petición no se define explícitamente, por lo que fetch realiza su acción predeterminada, que es una petición GET.
Una vez que la respuesta ha llegado, se verifica el éxito de la petición usando la propiedad response.ok, y se lanza un error si es necesario:
if (!response.ok) {
throw new Error('Failed to fetch notes')
}El atributo response.ok se establece en true si la petición fue exitosa, es decir, el código de estado de la respuesta está entre 200 y 299. Para todos los demás códigos de estado, como 404 o 500, se establece en false.
Ten en cuenta que fetch no lanza automáticamente un error incluso si el código de estado de la respuesta es, por ejemplo, 404. El manejo de errores debe implementarse manualmente, como lo hemos hecho aquí.
Si la petición es exitosa, los datos contenidos en la respuesta se convierten a formato JSON:
const data = await response.json()fetch no convierte automáticamente ningún dato incluido en la respuesta a formato JSON; la conversión debe hacerse manualmente. También es importante notar que response.json() es un método asíncrono, por lo que se requiere la palabra clave await.
Simplifiquemos aún más el código devolviendo directamente los datos devueltos por el método response.json():
const getAll = async () => {
const response = await fetch(baseUrl)
if (!response.ok) {
throw new Error('Failed to fetch notes')
}
return await response.json()}Inicializando el store con datos obtenidos del servidor
Modifiquemos ahora nuestra aplicación para que el estado de la aplicación se inicialice con las notas obtenidas del servidor.
En el archivo noteReducer.js, cambiemos la inicialización del estado de las notas para que por defecto no haya notas:
const noteSlice = createSlice({
name: 'notes',
initialState: [], // ...
})Agreguemos un action creator llamado setNotes, que nos permita reemplazar directamente el array de notas. Podemos crear el action creator deseado usando la función createSlice de la siguiente manera:
// ...
const noteSlice = createSlice({
name: 'notes',
initialState: [],
reducers: {
createNote(state, action) {
const content = action.payload
state.push({
content,
important: false,
id: generateId()
})
},
toggleImportanceOf(state, action) {
const id = action.payload
const noteToChange = state.find(n => n.id === id)
const changedNote = {
...noteToChange,
important: !noteToChange.important
}
return state.map(note => (note.id !== id ? note : changedNote))
},
setNotes(state, action) { return action.payload } }
})
export const { createNote, toggleImportanceOf, setNotes } = noteSlice.actionsexport default noteSlice.reducerImplementemos la inicialización de las notas en el componente App. Como es habitual al obtener datos de un servidor, usaremos el hook useEffect:
import { useEffect } from 'react'import { useDispatch } from 'react-redux'
import NoteForm from './components/NoteForm'
import Notes from './components/Notes'
import VisibilityFilter from './components/VisibilityFilter'
import { setNotes } from './reducers/noteReducer'import noteService from './services/notes'
const App = () => {
const dispatch = useDispatch()
useEffect(() => { noteService.getAll().then(notes => dispatch(setNotes(notes))) }, [dispatch])
return (
<div>
<NoteForm />
<VisibilityFilter />
<Notes />
</div>
)
}
export default AppEnviando datos al backend
A continuación, implementemos la funcionalidad para enviar una nueva nota al servidor. Esto también nos dará una oportunidad de practicar cómo hacer una petición POST usando el método fetch().
Expandamos el código en src/services/notes.js que maneja la comunicación con el servidor de la siguiente manera:
const baseUrl = 'http://localhost:3001/notes'
const getAll = async () => {
const response = await fetch(baseUrl)
if (!response.ok) {
throw new Error('Failed to fetch notes')
}
return await response.json()
}
const createNew = async (content) => { const response = await fetch(baseUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content, important: false }), }) if (!response.ok) { throw new Error('Failed to create note') } return await response.json()}
export default { getAll, createNew }Examinemos más de cerca la implementación del método createNew. El primer parámetro de la función fetch() especifica la URL a la que se realiza la petición. El segundo parámetro es un objeto que define otros detalles de la petición, como el tipo de petición, encabezados y los datos enviados con la petición. Podemos aclarar aún más el código almacenando el objeto que define los detalles de la petición en una variable options separada:
const createNew = async (content) => {
const options = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content, important: false }), } const response = await fetch(baseUrl, options)
if (!response.ok) {
throw new Error('Failed to create note')
}
return await response.json()
}Examinemos más de cerca el objeto options:
- method define el tipo de petición, que en este caso es POST
- headers define los encabezados de la petición. Agregamos el encabezado 'Content-Type': 'application/json' para informar al servidor que los datos enviados con la petición están en formato JSON, para que pueda manejar la petición correctamente
- body contiene los datos enviados con la petición. No puedes asignar directamente un objeto JavaScript a este campo; primero debe convertirse a una cadena JSON llamando a la función JSON.stringify()
Al igual que con una petición GET, el código de estado de la respuesta se verifica para detectar errores:
if (!response.ok) {
throw new Error('Failed to create note')
}Si la petición es exitosa, JSON Server devuelve la nota recién creada, para la cual también ha generado un id único. Sin embargo, los datos contenidos en la respuesta aún deben convertirse a formato JSON usando el método response.json():
return await response.json()Luego modificaremos el componente de nuestra aplicación NoteForm para que una nueva nota se envíe al backend. El método addNote del componente cambiará ligeramente:
import { useDispatch } from 'react-redux'
import { createNote } from '../reducers/noteReducer'
import noteService from '../services/notes'
const NoteForm = (props) => {
const dispatch = useDispatch()
const addNote = async (event) => { event.preventDefault()
const content = event.target.note.value
event.target.note.value = ''
const newNote = await noteService.createNew(content) dispatch(createNote(newNote)) }
return (
<form onSubmit={addNote}>
<input name="note" />
<button type="submit">add</button>
</form>
)
}
export default NoteFormCuando se crea una nueva nota en el backend llamando al método createNew(), el valor de retorno es un objeto que representa la nota, al cual el backend ha generado un id único. Por lo tanto, modifiquemos el action creator createNote definido en notesReducer.js de la siguiente manera:
const noteSlice = createSlice({
name: 'notes',
initialState: [],
reducers: {
createNote(state, action) {
state.push(action.payload) },
// ..
},
})El cambio de importancia de las notas podría implementarse utilizando el mismo principio, haciendo una llamada asíncrona al servidor y luego enviando una acción apropiada.
El estado actual del código para la aplicación se puede encontrar en GitHub en la rama part6-4.
Acciones asíncronas y Redux Thunk
Nuestro enfoque es bastante bueno, pero no es muy bueno que la comunicación con el servidor suceda dentro de las funciones de los componentes. Sería mejor si la comunicación pudiera abstraerse de los componentes para que no tengan que hacer nada más que llamar al action creator apropiado. Como ejemplo, App inicializaría el estado de la aplicación de la siguiente manera:
const App = () => {
const dispatch = useDispatch()
useEffect(() => {
dispatch(initializeNotes())
}, [dispatch])
// ...
}y NoteForm crearía una nueva nota de la siguiente manera:
const NoteForm = () => {
const dispatch = useDispatch()
const addNote = async (event) => {
event.preventDefault()
const content = event.target.note.value
event.target.note.value = ''
dispatch(createNote(content))
}
// ...
}En esta implementación, ambos componentes enviarían una acción sin necesidad de saber sobre la comunicación con el servidor que sucede detrás de escena. Estos tipos de acciones asíncronas se pueden implementar utilizando la librería Redux Thunk. El uso de la librería no requiere ninguna configuración adicional o incluso instalación cuando el store de Redux se ha creado utilizando la función configureStore del kit de herramientas de Redux (Redux Toolkit).
Con Redux Thunk, es posible implementar action creators que devuelven una función en lugar de un objeto. La función recibe los métodos dispatch y getState del store de Redux como parámetros. Esto permite, por ejemplo, implementaciones de action creators asíncronos, que primero esperan la finalización de una cierta operación asíncrona y luego despachan alguna acción, que cambia el estado del store.
Podemos definir un action creator llamado initializeNotes en el archivo noteReducer.js, que obtiene las notas iniciales del servidor, de la siguiente manera:
import { createSlice } from '@reduxjs/toolkit'
import noteService from '../services/notes'
const noteSlice = createSlice({
name: 'notes',
initialState: [],
reducers: {
createNote(state, action) {
state.push(action.payload)
},
toggleImportanceOf(state, action) {
const id = action.payload
const noteToChange = state.find((n) => n.id === id)
const changedNote = {
...noteToChange,
important: !noteToChange.important,
}
return state.map((note) => (note.id !== id ? note : changedNote))
},
setNotes(state, action) {
return action.payload
},
},
})
const { setNotes } = noteSlice.actions
export const initializeNotes = () => { return async (dispatch) => { const notes = await noteService.getAll() dispatch(setNotes(notes)) }}
export const { createNote, toggleImportanceOf } = noteSlice.actions
export default noteSlice.reducerEn su función interna, es decir, en la acción asíncrona, la operación primero obtiene todas las notas del servidor y luego despacha la acción para agregarlas al store. Es importante destacar que Redux pasa automáticamente una referencia al método dispatch como argumento a la función, por lo que el action creator initializeNotes no requiere ningún parámetro.
El action creator setNotes ya no se exporta fuera del módulo, ya que el estado inicial de las notas ahora se establecerá usando el action creator asíncrono initializeNotes que creamos. Sin embargo, todavía usamos el action creator setNotes dentro del módulo.
El componente App ahora puede definirse de la siguiente manera:
import { useEffect } from 'react'
import { useDispatch } from 'react-redux'
import NoteForm from './components/NoteForm'
import Notes from './components/Notes'
import VisibilityFilter from './components/VisibilityFilter'
import { initializeNotes } from './reducers/noteReducer'
const App = () => {
const dispatch = useDispatch()
useEffect(() => {
dispatch(initializeNotes()) }, [dispatch])
return (
<div>
<NoteForm />
<VisibilityFilter />
<Notes />
</div>
)
}
export default AppLa solución es bastante elegante. La lógica de inicialización de las notas se ha separado completamente del componente React.
A continuación, creemos un action creator asíncrono llamado appendNote:
import { createSlice } from '@reduxjs/toolkit'
import noteService from '../services/notes'
const noteSlice = createSlice({
name: 'notes',
initialState: [],
reducers: {
createNote(state, action) {
state.push(action.payload)
},
toggleImportanceOf(state, action) {
const id = action.payload
const noteToChange = state.find((n) => n.id === id)
const changedNote = {
...noteToChange,
important: !noteToChange.important,
}
return state.map((note) => (note.id !== id ? note : changedNote))
},
setNotes(state, action) {
return action.payload
},
},
})
const { createNote, setNotes } = noteSlice.actions
export const initializeNotes = () => {
return async (dispatch) => {
const notes = await noteService.getAll()
dispatch(setNotes(notes))
}
}
export const appendNote = (content) => { return async (dispatch) => { const newNote = await noteService.createNew(content) dispatch(createNote(newNote)) }}
export const { toggleImportanceOf } = noteSlice.actions
export default noteSlice.reducerEl principio es el mismo una vez más. Primero se realiza una operación asíncrona y, una vez completada, se despacha una acción que actualiza el estado del store. El action creator createNote ya no se exporta fuera del archivo; solo se usa internamente en la implementación de la función appendNote.
El componente NoteForm cambia de la siguiente manera:
import { useDispatch } from 'react-redux'
import { appendNote } from '../reducers/noteReducer'
const NoteForm = () => {
const dispatch = useDispatch()
const addNote = async (event) => {
event.preventDefault()
const content = event.target.note.value
event.target.note.value = ''
dispatch(appendNote(content)) }
return (
<form onSubmit={addNote}>
<input name="note" />
<button type="submit">add</button>
</form>
)
}Finalmente, limpiemos un poco el archivo main.jsx moviendo el código relacionado con la creación del store de Redux a su propio archivo store.js:
import { configureStore } from '@reduxjs/toolkit'
import noteReducer from './reducers/noteReducer'
import filterReducer from './reducers/filterReducer'
const store = configureStore({
reducer: {
notes: noteReducer,
filter: filterReducer
}
})
export default storeLuego de los cambios, el contenido del archivo main.jsx es el siguiente:
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'
import store from './store'import App from './App'
ReactDOM.createRoot(document.getElementById('root')).render(
<Provider store={store}>
<App />
</Provider>
)El estado actual del código de la aplicación se puede encontrar en GitHub en la rama part6-5.
Redux Toolkit ofrece una gran cantidad de herramientas para simplificar la administración de estado asíncrono. Las herramientas adecuadas para este caso de uso son, por ejemplo, la función createAsyncThunk y la API RTK Query.