1652116800
Un desarrollador frontend debería poder definir qué datos se necesitan para una página determinada, sin tener que preocuparse por cómo los datos llegan realmente a la interfaz.
Eso es lo que dijo un amigo mío recientemente en una discusión. ¿Por qué no hay una forma sencilla de obtener datos universales en NextJS?
Para responder a esta pregunta, echemos un vistazo a los desafíos relacionados con la obtención universal de datos en NextJS. Pero primero, ¿qué es realmente la obtención universal de datos?
Descargo de responsabilidad: Este va a ser un artículo largo y detallado. Va a cubrir mucho terreno y profundizará bastante en los detalles. Si espera un blog de marketing ligero, este artículo no es para usted.
Mi definición de obtención universal de datos es que puede colocar un gancho de obtención de datos en cualquier lugar de su aplicación, y simplemente funcionaría. Este gancho de obtención de datos debería funcionar en todas partes de su aplicación sin ninguna configuración adicional.
Aquí hay un ejemplo, probablemente el más complicado, pero estoy demasiado emocionado como para no compartirlo contigo.
Este es un gancho de "suscripción universal".
const PriceUpdates = () => {
const data = useSubscription.PriceUpdates();
return (
<div>
<h1>Universal Subscription</h1>
<p>{JSON.stringify(data)}</p>
</div>
)
}
Nuestro marco genera el gancho "PriceUpdates" ya que hemos definido un archivo "PriceUpdates.graphql" en nuestro proyecto.
¿Qué tiene de especial este gancho? Puede colocar React Component en cualquier lugar de su aplicación. De forma predeterminada, el servidor renderizará el primer elemento de la suscripción. El HTML generado por el servidor se enviará al cliente, junto con los datos. El cliente rehidratará la aplicación e iniciará una suscripción por sí mismo.
Todo esto se hace sin ninguna configuración adicional. Funciona en todas partes de su aplicación, de ahí el nombre, obtención universal de datos. Defina los datos que necesita, escribiendo una operación GraphQL, y el marco se encargará del resto.
Tenga en cuenta que no estamos tratando de ocultar el hecho de que se están realizando llamadas de red. Lo que estamos haciendo aquí es devolverles a los desarrolladores frontend su productividad. No debería preocuparse por cómo se obtienen los datos, cómo proteger la capa API, qué transporte usar, etc. Debería funcionar.
Si ha estado usando NextJS por un tiempo, es posible que se pregunte qué debería ser exactamente difícil en la obtención de datos.
En NextJS, simplemente puede definir un punto final en el directorio "/api", al que luego se puede llamar usando "swr" o simplemente "buscar".
Es correcto que el "¡Hola, mundo!" El ejemplo de obtener datos de "/api" es realmente simple, pero escalar una aplicación más allá de la primera página puede abrumar rápidamente al desarrollador.
Veamos los principales desafíos de la obtención de datos en NextJS.
De forma predeterminada, el único lugar donde puede usar funciones asíncronas para cargar datos necesarios para la representación del lado del servidor es en la raíz de cada página.
Aquí hay un ejemplo de la documentación de NextJS:
function Page({ data }) {
// Render data...
}
// This gets called on every request
export async function getServerSideProps() {
// Fetch data from external API
const res = await fetch(`https://.../data`)
const data = await res.json()
// Pass data to the page via props
return { props: { data } }
}
export default Page
Imagine un sitio web con cientos de páginas y componentes. Si tiene que definir todas las dependencias de datos en la raíz de cada página, ¿cómo sabe qué datos se necesitan realmente antes de representar el árbol de componentes? Dependiendo de los datos que haya cargado para los componentes raíz, alguna lógica podría decidir cambiar completamente los componentes secundarios.
He hablado con desarrolladores que tienen que mantener grandes aplicaciones de NextJS. Han declarado claramente que la obtención de datos en "getServerSideProps" no se escala bien con una gran cantidad de páginas y componentes.
La mayoría de las aplicaciones tienen algún tipo de mecanismo de autenticación. Puede haber algún contenido que esté disponible públicamente, pero ¿qué sucede si desea personalizar un sitio web?
Habrá una necesidad de renderizar diferentes contenidos para diferentes usuarios.
Cuando presenta contenido específico del usuario solo en el cliente, ¿ha notado este feo efecto de "parpadeo" una vez que ingresan los datos?
Si solo está representando el contenido específico del usuario en el cliente, siempre obtendrá el efecto de que la página se volverá a representar varias veces hasta que esté lista.
Idealmente, nuestros ganchos de obtención de datos serían conscientes de la autenticación desde el primer momento.
Como hemos visto en el ejemplo anterior usando "getServerSideProps", necesitamos tomar acciones adicionales para hacer que nuestra capa de API sea segura. ¿No sería mejor si los ganchos de obtención de datos fueran de tipo seguro por defecto?
Hasta ahora, nunca he visto a nadie que haya aplicado renderizado del lado del servidor en NextJS a las suscripciones. Pero, ¿qué sucede si desea representar el precio de las acciones en el servidor por razones de rendimiento y SEO, pero también desea tener una suscripción del lado del cliente para recibir actualizaciones?
Seguramente, podría usar una solicitud Query/GET en el servidor y luego agregar una suscripción en el cliente, pero esto agrega mucha complejidad. ¡Debería haber una manera más simple!
Otra pregunta que surge es qué debería pasar si el usuario sale y vuelve a entrar en la ventana. ¿Deberían detenerse las suscripciones o continuar transmitiendo datos? Según el caso de uso y el tipo de aplicación, es posible que desee modificar este comportamiento, según la experiencia esperada del usuario y el tipo de datos que está obteniendo. Nuestros ganchos de obtención de datos deberían poder manejar esto.
Es bastante común que las mutaciones tengan efectos secundarios en otros ganchos de obtención de datos. Por ejemplo, podría tener una lista de tareas. Cuando agrega una nueva tarea, también desea actualizar la lista de tareas. Por lo tanto, los ganchos de obtención de datos deben poder manejar este tipo de situaciones.
Otro patrón común es la carga diferida. Es posible que desee cargar datos solo en determinadas condiciones, por ejemplo, cuando el usuario se desplaza hasta la parte inferior de la página o cuando hace clic en un botón. En tales casos, nuestros ganchos de obtención de datos deberían poder diferir la ejecución de la obtención hasta que realmente se necesiten los datos.
Otro requisito importante para los ganchos de obtención de datos es eliminar el rebote de la ejecución de una consulta. Esto es para evitar solicitudes innecesarias al servidor. Imagine una situación en la que un usuario escribe un término de búsqueda en un cuadro de búsqueda. ¿Realmente debería hacer una solicitud al servidor cada vez que el usuario escribe una carta? Veremos cómo podemos usar el antirrebote para evitar esto y hacer que nuestros ganchos de obtención de datos sean más eficaces.
Eso nos lleva a 8 problemas centrales que debemos resolver. Analicemos ahora 21 patrones y mejores prácticas para resolver estos problemas.
Si desea seguir y experimentar estos patrones usted mismo, puede clonar este repositorio y jugar . Para cada patrón, hay una página dedicada en la demostración .
Una vez que haya iniciado la demostración, puede abrir su navegador y encontrar la descripción general de los patrones en http://localhost:3000/patterns
.
Notará que estamos usando GraphQL para definir nuestros ganchos de obtención de datos, pero la implementación realmente no es específica de GraphQL. Puede aplicar los mismos patrones con otros estilos de API como REST, o incluso con una API personalizada.
El primer patrón que veremos es el usuario del lado del cliente, es la base para construir ganchos de obtención de datos con reconocimiento de autenticación.
Aquí está el gancho para buscar al usuario actual:
useEffect(() => {
if (disableFetchUserClientSide) {
return;
}
const abort = new AbortController();
if (user === null) {
(async () => {
try {
const nextUser = await ctx.client.fetchUser(abort.signal);
if (JSON.stringify(nextUser) === JSON.stringify(user)) {
return;
}
setUser(nextUser);
} catch (e) {
}
})();
}
return () => {
abort.abort();
};
}, [disableFetchUserClientSide]);
Dentro de la raíz de nuestra página, usaremos este enlace para obtener el usuario actual (si aún no se obtuvo en el servidor). Es importante pasar siempre el controlador de cancelación al cliente, de lo contrario, podríamos tener pérdidas de memoria. La función de flecha de retorno se llama cuando se desmonta el componente que contiene el gancho.
Notará que estamos usando este patrón en toda nuestra aplicación para manejar las posibles fugas de memoria de manera adecuada.
Veamos ahora la implementación de "client.fetchUser".
public fetchUser = async (abortSignal?: AbortSignal, revalidate?: boolean): Promise<User<Role> | null> => {
try {
const revalidateTrailer = revalidate === undefined ? "" : "?revalidate=true";
const response = await fetch(this.baseURL + "/" + this.applicationPath + "/auth/cookie/user" + revalidateTrailer, {
headers: {
...this.extraHeaders,
"Content-Type": "application/json",
"WG-SDK-Version": this.sdkVersion,
},
method: "GET",
credentials: "include",
mode: "cors",
signal: abortSignal,
});
if (response.status === 200) {
return response.json();
}
} catch {
}
return null;
};
Notará que no estamos enviando ninguna credencial de cliente, token o cualquier otra cosa. Implícitamente enviamos la cookie segura, encriptada y solo http que configuró el servidor, a la que nuestro cliente no tiene acceso.
Para aquellos que no lo saben, las cookies de solo http se adjuntan automáticamente a cada solicitud si se encuentra en el mismo dominio. Si está utilizando HTTP/2, también es posible que el cliente y el servidor apliquen compresión de encabezado, lo que significa que la cookie no tiene que enviarse en cada solicitud, ya que tanto el cliente como el servidor pueden negociar un mapa de valor de clave de encabezado conocido. pares en el nivel de conexión.
El patrón que estamos usando detrás de escena para hacer que la autenticación sea tan simple se llama "Patrón de controlador de token". El patrón del controlador de tokens es la forma más segura de manejar la autenticación en las aplicaciones JavaScript modernas. Si bien es muy seguro, también nos permite ser independientes del proveedor de identidad.
Al aplicar el patrón del controlador de tokens, podemos cambiar fácilmente entre diferentes proveedores de identidad. Esto se debe a que nuestro "backend" actúa como una parte dependiente de OpenID Connect.
¿Qué es una parte dependiente, podrías preguntar? Es una aplicación con un cliente OpenID Connect que externaliza la autenticación a un tercero. Como estamos hablando en el contexto de OpenID Connect, nuestro "backend" es compatible con cualquier servicio que implemente el protocolo OpenID Connect. De esta forma, nuestro backend puede brindar una experiencia de autenticación perfecta, mientras que los desarrolladores pueden elegir entre diferentes proveedores de identidad, como Keycloak, Auth0, Okta, Ping Identity, etc.
¿Cómo se ve el flujo de autenticación desde la perspectiva de los usuarios?
A partir de ahora, cuando el cliente llame al fetchUser
método, enviará automáticamente la cookie al backend. De esta manera, la interfaz siempre tiene acceso a la información del usuario mientras está conectado.
Si el usuario hace clic en cerrar sesión, llamaremos a una función en el backend que invalidará la cookie.
Todo esto puede ser mucho para digerir, así que resumamos las partes esenciales. Primero, debe decirle al backend con qué proveedores de identidad trabajar para que pueda actuar como Reyling Party. Una vez hecho esto, podrá iniciar el flujo de autenticación desde el frontend, obtener al usuario actual del backend y cerrar la sesión.
Si envolvemos esta llamada "fetchUser" en un enlace useEffect
que colocamos en la raíz de cada página, siempre sabremos cuál es el usuario actual.
Sin embargo, hay una trampa. Si abre la demostración y se dirige a la página de usuario del lado del cliente , notará que hay un efecto de parpadeo después de cargar la página, eso se debe a que la fetchUser
llamada se está realizando en el cliente.
Si observa Chrome DevTools y abre la vista previa de la página, notará que la página se representa con el objeto de usuario establecido en null
. Puede hacer clic en el botón de inicio de sesión para iniciar el flujo de inicio de sesión. Una vez completado, actualice la página y verá el efecto de parpadeo.
Ahora que comprende la mecánica detrás del patrón del controlador de fichas, echemos un vistazo a cómo podemos eliminar el parpadeo en la carga de la primera página.
Si desea deshacerse del parpadeo, tenemos que cargar al usuario en el lado del servidor para que pueda aplicar la representación del lado del servidor. Al mismo tiempo, tenemos que llevar de alguna manera el usuario renderizado del lado del servidor al cliente. Si omitimos ese segundo paso, la rehidratación del cliente fallará ya que el html generado por el servidor diferirá del primer procesamiento del lado del cliente.
Entonces, ¿cómo obtenemos acceso al objeto de usuario en el lado del servidor? Recuerde que todo lo que tenemos es una cookie adjunta a un dominio.
Digamos que nuestro backend se ejecuta en api.example.com
, y el frontend se ejecuta en www.example.com
o example.com
.
Si hay algo importante que debe saber sobre las cookies es que puede establecer cookies en los dominios principales si está en un subdominio. Esto significa que, una vez que se completa el flujo de autenticación, el backend NO debe establecer la cookie en el api.example.com
dominio. En su lugar, debería establecer la cookie en el example.com
dominio. Al hacerlo, la cookie se vuelve visible para todos los subdominios de example.com
, incluido www.example.com
, api.example.com
y para example.com
ella misma.
Por cierto, este es un patrón excelente para implementar el inicio de sesión único. Haga que sus usuarios inicien sesión una vez y se autentican en todos los subdominios.
WunderGraph establece automáticamente las cookies en el dominio principal si el backend está en un subdominio, por lo que no tiene que preocuparse por esto.
Ahora, volvamos a poner al usuario en el lado del servidor. Para llevar al usuario del lado del servidor, tenemos que implementar alguna lógica en el getInitialProps
método de nuestras páginas.
WunderGraphPage.getInitialProps = async (ctx: NextPageContext) => {
// ... omitted for brevity
const cookieHeader = ctx.req?.headers.cookie;
if (typeof cookieHeader === "string") {
defaultContextProperties.client.setExtraHeaders({
Cookie: cookieHeader,
});
}
let ssrUser: User<Role> | null = null;
if (options?.disableFetchUserServerSide !== true) {
try {
ssrUser = await defaultContextProperties.client.fetchUser();
} catch (e) {
}
}
// ... omitted for brevity
return {...pageProps, ssrCache, user: ssrUser};
El ctx
objeto de la getInitialProps
función contiene la solicitud del cliente, incluidos los encabezados. Podemos hacer un "truco de magia" para que el "cliente API", que creamos en el lado del servidor, pueda actuar en nombre del usuario.
Como tanto el frontend como el backend comparten el mismo dominio principal, tenemos acceso a la cookie que configuró el backend. Entonces, si tomamos el encabezado de la cookie y lo configuramos como el Cookie
encabezado del cliente API, el cliente API podrá actuar en el contexto del usuario, ¡incluso en el lado del servidor!
Ahora podemos buscar al usuario en el lado del servidor y pasar el objeto de usuario junto con los pageProps a la función de representación de la página. Asegúrese de no perder este último paso, de lo contrario la rehidratación del cliente fallará.
Muy bien, hemos resuelto el problema del parpadeo, al menos cuando presionas actualizar. Pero, ¿qué sucede si comenzamos en una página diferente y usamos la navegación del lado del cliente para llegar a esta página?
Abra la demostración y pruébelo usted mismo. Verá que el objeto de usuario se establecerá en null
si el usuario no se cargó en la otra página.
Para resolver también este problema, tenemos que ir un paso más allá y aplicar el patrón de "usuario universal".
El patrón de usuario universal es la combinación de los dos patrones anteriores.
Si estamos accediendo a la página por primera vez, cargue al usuario en el lado del servidor, si es posible, y renderice la página. En el lado del cliente, rehidratamos la página con el objeto de usuario y no lo recuperamos, por lo tanto, no hay parpadeo.
En el segundo escenario, estamos usando la navegación del lado del cliente para llegar a nuestra página. En este caso, comprobamos si el usuario ya está cargado. Si el objeto de usuario es nulo, intentaremos recuperarlo.
¡Genial, tenemos el patrón de usuario universal en su lugar! Pero hay otro problema que podríamos enfrentar. ¿Qué sucede si el usuario abre una segunda pestaña o ventana y hace clic en el botón de cierre de sesión?
Abra la página de usuario universal en la demostración en dos pestañas o ventanas y pruébelo usted mismo. Si hace clic en cerrar sesión en una pestaña, luego regresa a la otra pestaña, verá que el objeto de usuario todavía está allí.
El patrón "recuperar usuario en el foco de la ventana" es una solución a este problema.
Afortunadamente, podemos usar el window.addEventListener
método para escuchar el focus
evento. De esta manera, recibimos una notificación cada vez que el usuario activa la pestaña o ventana.
Agreguemos un gancho a nuestra página para manejar eventos de ventana.
const windowHooks = (setIsWindowFocused: Dispatch<SetStateAction<"pristine" | "focused" | "blurred">>) => {
useEffect(() => {
const onFocus = () => {
setIsWindowFocused("focused");
};
const onBlur = () => {
setIsWindowFocused("blurred");
};
window.addEventListener('focus', onFocus);
window.addEventListener('blur', onBlur);
return () => {
window.removeEventListener('focus', onFocus);
window.removeEventListener('blur', onBlur);
};
}, []);
}
Notará que presentamos tres estados posibles para la acción "isWindowFocused": prístina, enfocada y borrosa. ¿Por qué tres estados? Imagina si tuviéramos solo dos estados, enfocado y borroso. En este caso, siempre tendríamos que disparar un evento de "enfoque", incluso si la ventana ya estaba enfocada. Al introducir el tercer estado (prístino), podemos evitar esto.
Otra observación importante que puede hacer es que estamos eliminando los detectores de eventos cuando se desmonta el componente. Esto es muy importante para evitar pérdidas de memoria.
Ok, hemos introducido un estado global para el foco de la ventana. Aprovechemos este estado para volver a buscar al usuario en el foco de la ventana agregando otro enlace:
useEffect(() => {
if (disableFetchUserClientSide) {
return;
}
if (disableFetchUserOnWindowFocus) {
return;
}
if (isWindowFocused !== "focused") {
return
}
const abort = new AbortController();
(async () => {
try {
const nextUser = await ctx.client.fetchUser(abort.signal);
if (JSON.stringify(nextUser) === JSON.stringify(user)) {
return;
}
setUser(nextUser);
} catch (e) {
}
})();
return () => {
abort.abort();
};
}, [isWindowFocused, disableFetchUserClientSide, disableFetchUserOnWindowFocus]);
Al agregar el isWindowFocused
estado a la lista de dependencias, este efecto se activará cada vez que cambie el enfoque de la ventana. Descartamos los eventos "prístinos" y "borrosos" y solo activamos una búsqueda de usuario si la ventana está enfocada.
Además, nos aseguramos de que solo activemos un estado setState para el usuario si realmente cambió. De lo contrario, podríamos activar renderizaciones o recuperaciones innecesarias.
¡Excelente! Nuestra aplicación ahora puede manejar la autenticación en varios escenarios. Esa es una gran base para pasar a los ganchos reales de obtención de datos.
El primer gancho de obtención de datos que veremos es la consulta del lado del cliente . Puede abrir la página de demostración (http://localhost:3000/patterns/client-side-query) en su navegador para familiarizarse con ella.
const data = useQuery.CountryWeather({
input: {
code: "DE",
},
});
Entonces, ¿qué hay detrás useQuery.CountryWeather
? ¡Echemos un vistazo!
function useQueryContextWrapper<Input, Data, Role>(wunderGraphContext: Context<WunderGraphContextProperties<Role>>, query: QueryProps, args?: InternalQueryArgsWithInput<Input>): {
result: QueryResult<Data>;
} {
const {client} = useContext(wunderGraphContext);
const cacheKey = client.cacheKey(query, args);
const [statefulArgs, setStatefulArgs] = useState<InternalQueryArgsWithInput<Input> | undefined>(args);
const [queryResult, setQueryResult] = useState<QueryResult<Data> | undefined>({status: "none"});
useEffect(() => {
if (lastCacheKey === "") {
setLastCacheKey(cacheKey);
return;
}
if (lastCacheKey === cacheKey) {
return;
}
setLastCacheKey(cacheKey);
setStatefulArgs(args);
setInvalidate(invalidate + 1);
}, [cacheKey]);
useEffect(() => {
const abort = new AbortController();
setQueryResult({status: "loading"});
(async () => {
const result = await client.query(query, {
...statefulArgs,
abortSignal: abort.signal,
});
setQueryResult(result as QueryResult<Data>);
})();
return () => {
abort.abort();
setQueryResult({status: "cancelled"});
}
}, [invalidate]);
return {
result: queryResult as QueryResult<Data>,
}
}
Vamos a explicar lo que está pasando aquí. Primero, tomamos el cliente que se está inyectando a través de React.Context. Luego calculamos una clave de caché para la consulta y los argumentos. Esta cacheKey nos ayuda a determinar si necesitamos volver a obtener los datos.
El estado inicial de la operación se establece en {status: "none"}
. Cuando se activa la primera obtención, el estado se establece en "loading"
. Cuando finaliza la búsqueda, el estado se establece en "success"
o "error"
. Si el componente que envuelve este gancho se está desmontando, el estado se establece en "cancelled"
.
Aparte de eso, nada especial está sucediendo aquí. La recuperación solo ocurre cuando se activa useEffect. Esto significa que no podemos ejecutar la búsqueda en el servidor. React.Hooks no se ejecuta en el servidor.
Si observa la demostración, notará que vuelve a parpadear. Esto se debe a que no estamos procesando el componente en el servidor. ¡Mejoremos esto!
Para ejecutar consultas no solo en el cliente sino también en el servidor, debemos aplicar algunos cambios a nuestros ganchos.
Primero actualicemos el useQuery
gancho.
function useQueryContextWrapper<Input, Data, Role>(wunderGraphContext: Context<WunderGraphContextProperties<Role>>, query: QueryProps, args?: InternalQueryArgsWithInput<Input>): {
result: QueryResult<Data>;
} {
const {ssrCache, client, isWindowFocused, refetchMountedOperations, user} = useContext(wunderGraphContext);
const isServer = typeof window === 'undefined';
const ssrEnabled = args?.disableSSR !== true && args?.lazy !== true;
const cacheKey = client.cacheKey(query, args);
if (isServer) {
if (ssrEnabled) {
if (ssrCache[cacheKey]) {
return {
result: ssrCache[cacheKey] as QueryResult<Data>,
}
}
const promise = client.query(query, args);
ssrCache[cacheKey] = promise;
throw promise;
} else {
ssrCache[cacheKey] = {
status: "none",
};
return {
result: ssrCache[cacheKey] as QueryResult<Data>,
}
}
}
const [invalidate, setInvalidate] = useState<number>(0);
const [statefulArgs, setStatefulArgs] = useState<InternalQueryArgsWithInput<Input> | undefined>(args);
const [lastCacheKey, setLastCacheKey] = useState<string>("");
const [queryResult, setQueryResult] = useState<QueryResult<Data> | undefined>(ssrCache[cacheKey] as QueryResult<Data> || {status: "none"});
useEffect(() => {
if (lastCacheKey === "") {
setLastCacheKey(cacheKey);
return;
}
if (lastCacheKey === cacheKey) {
return;
}
setLastCacheKey(cacheKey);
setStatefulArgs(args);
if (args?.debounceMillis !== undefined) {
setDebounce(prev => prev + 1);
return;
}
setInvalidate(invalidate + 1);
}, [cacheKey]);
useEffect(() => {
setQueryResult({status: "loading"});
(async () => {
const result = await client.query(query, {
...statefulArgs,
abortSignal: abort.signal,
});
setQueryResult(result as QueryResult<Data>);
})();
return () => {
abort.abort();
setQueryResult({status: "cancelled"});
}
}, [invalidate]);
return {
result: queryResult as QueryResult<Data>,
}
}
Ahora hemos actualizado el enlace useQuery para verificar si estamos en el servidor o no. Si estamos en el servidor, verificaremos si los datos ya se resolvieron para la clave de caché generada. Si los datos fueron resueltos, los devolveremos. De lo contrario, usaremos el cliente para ejecutar la consulta mediante una Promesa. Pero hay un problema. No se nos permite ejecutar código asíncrono mientras se renderiza en el servidor. Entonces, en teoría, no podemos "esperar" a que se resuelva la promesa.
En cambio, tenemos que usar un truco. Necesitamos "suspender" el renderizado. Podemos hacerlo "lanzando" la promesa que acabamos de crear.
Imagine que estamos renderizando el componente envolvente en el servidor. Lo que podríamos hacer es envolver el proceso de renderizado de cada componente en un bloque try/catch. Si uno de esos componentes arroja una promesa, podemos capturarlo, esperar hasta que se resuelva la promesa y luego volver a procesar el componente.
Una vez que se resuelve la promesa, podemos llenar la clave de caché con el resultado. De esta manera, podemos devolver los datos inmediatamente cuando "intentamos" renderizar el componente por segunda vez. Con este método, podemos movernos por el árbol de componentes y ejecutar todas las consultas que están habilitadas para la representación del lado del servidor.
Quizás se pregunte cómo implementar este método de prueba/captura. Por suerte, no tenemos que empezar de cero. Hay una biblioteca llamada react-ssr-prepass que podemos usar para hacer esto.
Apliquemos esto a nuestra getInitialProps
función:
WithWunderGraph.getInitialProps = async (ctx: NextPageContext) => {
const pageProps = (Page as NextPage).getInitialProps ? await (Page as NextPage).getInitialProps!(ctx as any) : {};
const ssrCache: { [key: string]: any } = {};
if (typeof window !== 'undefined') {
// we're on the client
// no need to do all the SSR stuff
return {...pageProps, ssrCache};
}
const cookieHeader = ctx.req?.headers.cookie;
if (typeof cookieHeader === "string") {
defaultContextProperties.client.setExtraHeaders({
Cookie: cookieHeader,
});
}
let ssrUser: User<Role> | null = null;
if (options?.disableFetchUserServerSide !== true) {
try {
ssrUser = await defaultContextProperties.client.fetchUser();
} catch (e) {
}
}
const AppTree = ctx.AppTree;
const App = createElement(wunderGraphContext.Provider, {
value: {
...defaultContextProperties,
user: ssrUser,
},
}, createElement(AppTree, {
pageProps: {
...pageProps,
},
ssrCache,
user: ssrUser
}));
await ssrPrepass(App);
const keys = Object.keys(ssrCache).filter(key => typeof ssrCache[key].then === 'function').map(key => ({
key,
value: ssrCache[key]
})) as { key: string, value: Promise<any> }[];
if (keys.length !== 0) {
const promises = keys.map(key => key.value);
const results = await Promise.all(promises);
for (let i = 0; i < keys.length; i++) {
const key = keys[i].key;
ssrCache[key] = results[i];
}
}
return {...pageProps, ssrCache, user: ssrUser};
};
El ctx
objeto no solo contiene el req
objeto sino también los AppTree
objetos. Usando el AppTree
objeto, podemos construir todo el árbol de componentes e inyectar nuestro proveedor de contexto, el ssrCache
objeto y el user
objeto.
Luego podemos usar la ssrPrepass
función para atravesar el árbol de componentes y ejecutar todas las consultas que están habilitadas para la representación del lado del servidor. Después de hacerlo, extraemos los resultados de todas las Promesas y completamos el ssrCache
objeto. Finalmente, devolvemos el pageProps
objeto y el ssrCache
objeto así como el user
objeto.
¡Fantástico! ¡Ahora podemos aplicar la representación del lado del servidor a nuestro enlace useQuery!
Vale la pena mencionar que hemos desvinculado por completo la representación del lado del servidor de tener que implementarla getServerSideProps
en nuestro Page
componente. Esto tiene algunos efectos que es importante discutir.
Primero, hemos resuelto el problema de que tenemos que declarar nuestras dependencias de datos en getServerSideProps
. Somos libres de colocar nuestros ganchos useQuery en cualquier parte del árbol de componentes, siempre se ejecutarán.
Por otro lado, este enfoque tiene la desventaja de que esta página no estará optimizada estáticamente. En su lugar, la página siempre se procesará en el servidor, lo que significa que debe haber un servidor ejecutándose para servir la página. Otro enfoque sería crear una página renderizada estáticamente, que se puede servir completamente desde un CDN.
Dicho esto, asumimos en esta guía que su objetivo es ofrecer contenido dinámico que cambia según el usuario. En este escenario, la representación estática de la página no será una opción, ya que no tenemos ningún contexto de usuario al obtener los datos.
Es genial lo que hemos logrado hasta ahora. Pero, ¿qué debería pasar si el usuario deja la ventana por un tiempo y vuelve? ¿Es posible que los datos que hemos obtenido en el pasado estén desactualizados? Si es así, ¿cómo podemos hacer frente a esta situación? ¡Al siguiente patrón!
Afortunadamente, ya implementamos un objeto de contexto global para propagar los tres estados de enfoque de ventana diferentes: prístino, borroso y enfocado.
Aprovechemos el estado "enfocado" para activar una recuperación de la consulta.
Recuerde que estábamos usando el contador "invalidar" para activar una recuperación de la consulta. Podemos agregar un nuevo efecto para aumentar este contador siempre que la ventana esté enfocada.
useEffect(() => {
if (!refetchOnWindowFocus) {
return;
}
if (isWindowFocused !== "focused") {
return;
}
setInvalidate(prev => prev + 1);
}, [refetchOnWindowFocus, isWindowFocused]);
¡Eso es todo! Descartamos todos los eventos si refetchOnWindowFocus se establece en falso o si la ventana no está enfocada. De lo contrario, aumentamos el contador de invalidaciones y activamos una nueva búsqueda de la consulta.
Si está siguiendo la demostración, eche un vistazo a la página de refetch-query-on-window-focus .
El enlace, incluida la configuración, se ve así:
const data = useQuery.CountryWeather({
input: {
code: "DE",
},
disableSSR: true,
refetchOnWindowFocus: true,
});
¡Eso fue rápido! Pasemos al siguiente patrón, carga diferida.
Como se discutió en el enunciado del problema, algunas de nuestras operaciones deben ejecutarse solo después de un evento específico. Hasta entonces, la ejecución debe ser aplazada.
Echemos un vistazo a la página de consulta diferida .
const [args,setArgs] = useState<QueryArgsWithInput<CountryWeatherInput>>({
input: {
code: "DE",
},
lazy: true,
});
Establecer perezoso en verdadero configura el gancho para que sea "perezoso". Ahora, veamos la implementación:
useEffect(() => {
if (lazy && invalidate === 0) {
setQueryResult({
status: "lazy",
});
return;
}
const abort = new AbortController();
setQueryResult({status: "loading"});
(async () => {
const result = await client.query(query, {
...statefulArgs,
abortSignal: abort.signal,
});
setQueryResult(result as QueryResult<Data>);
})();
return () => {
abort.abort();
setQueryResult({status: "cancelled"});
}
}, [invalidate]);
const refetch = useCallback((args?: InternalQueryArgsWithInput<Input>) => {
if (args !== undefined) {
setStatefulArgs(args);
}
setInvalidate(prev => prev + 1);
}, []);
Cuando este hook se ejecuta por primera vez, lazy se establecerá en true y invalidate se establecerá en 0. Esto significa que el hook de efecto regresará temprano y establecerá el resultado de la consulta en "lazy". No se ejecuta una búsqueda en este escenario.
Si queremos ejecutar la consulta, tenemos que aumentar la invalidación en 1. Podemos hacerlo llamando refetch
al gancho useQuery.
¡Eso es todo! La carga diferida ahora está implementada.
Pasemos al siguiente problema: eliminar las entradas de los usuarios para no obtener la consulta con demasiada frecuencia.
Digamos que el usuario quiere obtener el clima de una ciudad específica. Mi ciudad natal es "Frankfurt am Main", justo en el centro de Alemania. Ese término de búsqueda tiene 17 caracteres. ¿Con qué frecuencia debemos obtener la consulta mientras el usuario está escribiendo? 17 veces? ¿Una vez? ¿Quizás dos veces?
La respuesta estará en algún punto intermedio, pero definitivamente no es 17 veces. Entonces, ¿cómo podemos implementar este comportamiento? Echemos un vistazo a la implementación del gancho useQuery.
useEffect(() => {
if (debounce === 0) {
return;
}
const cancel = setTimeout(() => {
setInvalidate(prev => prev + 1);
}, args?.debounceMillis || 0);
return () => clearTimeout(cancel);
}, [debounce]);
useEffect(() => {
if (lastCacheKey === "") {
setLastCacheKey(cacheKey);
return;
}
if (lastCacheKey === cacheKey) {
return;
}
setLastCacheKey(cacheKey);
setStatefulArgs(args);
if (args?.debounceMillis !== undefined) {
setDebounce(prev => prev + 1);
return;
}
setInvalidate(invalidate + 1);
}, [cacheKey]);
Primero echemos un vistazo al segundo useEffect, el que tiene cacheKey como dependencia. Puede ver que antes de aumentar el contador de invalidaciones, verificamos si los argumentos de la operación contienen una propiedad debounceMillis. Si es así, no aumentamos inmediatamente el contador de invalidaciones. En su lugar, aumentamos el contador de rebotes.
Aumentar el contador de rebote activará el primer useEffect, ya que el contador de rebote es una dependencia. Si el contador de rebotes es 0, que es el valor inicial, regresamos inmediatamente, ya que no hay nada que hacer. De lo contrario, iniciamos un temporizador usando setTimeout. Una vez que se activa el tiempo de espera, aumentamos el contador de invalidaciones.
Lo especial del efecto que usa setTimeout es que estamos aprovechando la función de retorno del gancho del efecto para borrar el tiempo de espera. Lo que esto significa es que si el usuario escribe más rápido que el tiempo de rebote, el temporizador siempre se borra y el contador de invalidaciones no aumenta. Solo cuando ha pasado el tiempo completo de rebote, se incrementa el contador de invalidaciones.
Veo a menudo que los desarrolladores usan setTimeout pero se olvidan de manejar el objeto que regresa. No manejar el valor de retorno de setTimeout podría provocar pérdidas de memoria, ya que también es posible que el componente React adjunto se desmonte antes de que se active el tiempo de espera.
Si está interesado en jugar, diríjase a la demostración e intente escribir diferentes términos de búsqueda utilizando varios tiempos de rebote.
¡Estupendo! Tenemos una buena solución para contrarrestar las entradas de los usuarios. Veamos ahora las operaciones que requieren que el usuario esté autenticado. Comenzaremos con una consulta protegida del lado del servidor.
Digamos que estamos representando un tablero que requiere que el usuario esté autenticado. El tablero también mostrará datos específicos del usuario. ¿Cómo podemos implementar esto? Nuevamente, tenemos que modificar el gancho useQuery.
const {ssrCache, client, isWindowFocused, refetchMountedOperations, user} = useContext(wunderGraphContext);
const isServer = typeof window === 'undefined';
const ssrEnabled = args?.disableSSR !== true && args?.lazy !== true;
const cacheKey = client.cacheKey(query, args);
if (isServer) {
if (query.requiresAuthentication && user === null) {
ssrCache[cacheKey] = {
status: "requires_authentication"
};
return {
result: ssrCache[cacheKey] as QueryResult<Data>,
refetch: () => {
},
};
}
if (ssrEnabled) {
if (ssrCache[cacheKey]) {
return {
result: ssrCache[cacheKey] as QueryResult<Data>,
refetch: () => Promise.resolve(ssrCache[cacheKey] as QueryResult<Data>),
}
}
const promise = client.query(query, args);
ssrCache[cacheKey] = promise;
throw promise;
} else {
ssrCache[cacheKey] = {
status: "none",
};
return {
result: ssrCache[cacheKey] as QueryResult<Data>,
refetch: () => ({}),
}
}
}
Como discutimos en el patrón 2, Usuario del lado del servidor, ya implementamos alguna lógica para obtener el objeto del usuario getInitialProps
e inyectarlo en el contexto. También inyectamos la cookie de usuario en el cliente, que también se inyecta en el contexto. Juntos, estamos listos para implementar la consulta protegida del lado del servidor.
Si estamos en el servidor, comprobamos si la consulta requiere autenticación. Esta es información estática que se define en los metadatos de la consulta. Si el objeto de usuario es nulo, lo que significa que el usuario no está autenticado, devolvemos un resultado con el estado "requires_authentication". De lo contrario, avanzamos y lanzamos una promesa o devolvemos el resultado del caché.
Si va a la consulta protegida del lado del servidor en la demostración, puede jugar con esta implementación y ver cómo se comporta cuando inicia y cierra sesión.
Eso es todo, sin magia. Eso no fue demasiado complicado, ¿verdad? Bueno, el servidor no permite ganchos, lo que hace que la lógica sea mucho más fácil. Veamos ahora lo que se requiere para implementar la misma lógica en el cliente.
Para implementar la misma lógica para el cliente, necesitamos modificar el enlace useQuery una vez más.
useEffect(() => {
if (query.requiresAuthentication && user === null) {
setQueryResult({
status: "requires_authentication",
});
return;
}
if (lazy && invalidate === 0) {
setQueryResult({
status: "lazy",
});
return;
}
const abort = new AbortController();
if (queryResult?.status === "ok") {
setQueryResult({...queryResult, refetching: true});
} else {
setQueryResult({status: "loading"});
}
(async () => {
const result = await client.query(query, {
...statefulArgs,
abortSignal: abort.signal,
});
setQueryResult(result as QueryResult<Data>);
})();
return () => {
abort.abort();
setQueryResult({status: "cancelled"});
}
}, [invalidate, user]);
Como puede ver, ahora hemos agregado el objeto de usuario a las dependencias del efecto. Si la consulta requiere autenticación, pero el objeto de usuario es nulo, establecemos el resultado de la consulta en "requires_authentication" y regresamos antes, no se realiza ninguna búsqueda. Si pasamos esta verificación, la consulta se activa como de costumbre.
Hacer que el objeto del usuario dependa del efecto de búsqueda también tiene dos buenos efectos secundarios.
Digamos que una consulta requiere que el usuario esté autenticado, pero actualmente no lo está. El resultado de la consulta inicial es "requires_authentication". Si el usuario ahora inicia sesión, el objeto de usuario se actualiza a través del objeto de contexto. Como el objeto de usuario es una dependencia del efecto de búsqueda, todas las consultas ahora se activan nuevamente y el resultado de la consulta se actualiza.
Por otro lado, si una consulta requiere que el usuario esté autenticado y el usuario acaba de cerrar sesión, invalidaremos automáticamente todas las consultas y estableceremos los resultados en "requires_authentication".
¡Excelente! Ahora hemos implementado el patrón de consulta protegido del lado del cliente. Pero ese no es todavía el resultado ideal.
Si está utilizando consultas protegidas del lado del servidor, la navegación del lado del cliente no se maneja correctamente. Por otro lado, si solo usamos consultas protegidas del lado del cliente, siempre volveremos a tener el desagradable parpadeo.
Para resolver estos problemas, tenemos que juntar ambos patrones, lo que nos lleva al patrón de consulta protegido universal.
Este patrón no requiere ningún cambio adicional ya que ya hemos implementado toda la lógica. Todo lo que tenemos que hacer es configurar nuestra página para activar el patrón de consulta protegido universal.
Aquí está el código de la página de consulta protegida universal :
const UniversalProtectedQuery = () => {
const {user,login,logout} = useWunderGraph();
const data = useQuery.ProtectedWeather({
input: {
city: "Berlin",
},
});
return (
<div>
<h1>Universal Protected Query</h1>
<p>{JSON.stringify(user)}</p>
<p>{JSON.stringify(data)}</p>
<button onClick={() => login(AuthProviders.github)}>Login</button>
<button onClick={() => logout()}>Logout</button>
</div>
)
}
export default withWunderGraph(UniversalProtectedQuery);
Juegue con la demostración y vea cómo se comporta cuando inicia y cierra sesión. También intente actualizar la página o use la navegación del lado del cliente.
Lo bueno de este patrón es lo simple que es la implementación real de la página. El gancho de consulta "ProtectedWeather" abstrae toda la complejidad del manejo de la autenticación, tanto del lado del cliente como del lado del servidor.
Correcto, hemos dedicado mucho tiempo a las consultas hasta ahora, ¿qué pasa con las mutaciones? Comencemos con una mutación desprotegida, una que no requiere autenticación. Verá que los enlaces de mutación son mucho más fáciles de implementar que los enlaces de consulta.
function useMutationContextWrapper<Role, Input = never, Data = never>(wunderGraphContext: Context<WunderGraphContextProperties<Role>>, mutation: MutationProps): {
result: MutationResult<Data>;
mutate: (args?: InternalMutationArgsWithInput<Input>) => Promise<MutationResult<Data>>;
} {
const {client, user} = useContext(wunderGraphContext);
const [result, setResult] = useState<MutationResult<Data>>(mutation.requiresAuthentication && user === null ? {status: "requires_authentication"} : {status: "none"});
const mutate = useCallback(async (args?: InternalMutationArgsWithInput<Input>): Promise<MutationResult<Data>> => {
setResult({status: "loading"});
const result = await client.mutate(mutation, args);
setResult(result as any);
return result as any;
}, []);
return {
result,
mutate
}
}
Las mutaciones no se activan automáticamente. Esto significa que no estamos usando useEffect para desencadenar la mutación. En su lugar, estamos aprovechando el enlace useCallback para crear una función de "mutación" a la que se puede llamar.
Una vez llamado, establecemos el estado del resultado en "cargando" y luego llamamos a la mutación. Cuando finaliza la mutación, establecemos el estado del resultado en el resultado de la mutación. Esto puede ser un éxito o un fracaso. Finalmente, devolvemos tanto el resultado como la función de mutación.
Echa un vistazo a la página de mutaciones sin protección si quieres jugar con este patrón.
Esto fue bastante sencillo. Agreguemos algo de complejidad agregando autenticación.
function useMutationContextWrapper<Role, Input = never, Data = never>(wunderGraphContext: Context<WunderGraphContextProperties<Role>>, mutation: MutationProps): {
result: MutationResult<Data>;
mutate: (args?: InternalMutationArgsWithInput<Input>) => Promise<MutationResult<Data>>;
} {
const {client, user} = useContext(wunderGraphContext);
const [result, setResult] = useState<MutationResult<Data>>(mutation.requiresAuthentication && user === null ? {status: "requires_authentication"} : {status: "none"});
const mutate = useCallback(async (args?: InternalMutationArgsWithInput<Input>): Promise<MutationResult<Data>> => {
if (mutation.requiresAuthentication && user === null) {
return {status: "requires_authentication"}
}
setResult({status: "loading"});
const result = await client.mutate(mutation, args);
setResult(result as any);
return result as any;
}, [user]);
useEffect(() => {
if (!mutation.requiresAuthentication) {
return
}
if (user === null) {
if (result.status !== "requires_authentication") {
setResult({status: "requires_authentication"});
}
return;
}
if (result.status !== "none") {
setResult({status: "none"});
}
}, [user]);
return {
result,
mutate
}
}
De manera similar al patrón de consulta protegida, estamos inyectando el objeto de usuario del contexto en la devolución de llamada. Si la mutación requiere autenticación, verificamos si el usuario es nulo. Si el usuario es nulo, establecemos el resultado en "requires_authentication" y regresamos antes.
Además, agregamos un efecto para verificar si el usuario es nulo. Si el usuario es nulo, establecemos el resultado en "requires_authentication". Hicimos esto para que las mutaciones cambien automáticamente al estado "requires_authentication" o "ninguno", dependiendo de si el usuario está autenticado o no. De lo contrario, primero tendría que llamar a la mutación para darse cuenta de que no es posible llamar a la mutación. Creo que nos brinda una mejor experiencia de desarrollador cuando está claro por adelantado si la mutación es posible o no.
Muy bien, las mutaciones protegidas ya están implementadas. Quizás se pregunte por qué no hay una sección sobre mutaciones del lado del servidor, protegidas o no. Eso es porque las mutaciones siempre se desencadenan por la interacción del usuario. Por lo tanto, no es necesario que implementemos nada en el servidor.
Dicho esto, queda un problema con las mutaciones, ¡los efectos secundarios! ¿Qué sucede si hay una dependencia entre una lista de tareas y una mutación que cambia las tareas? ¡Hagamos que suceda!
Para que esto funcione, necesitamos cambiar tanto la devolución de llamada de mutación como el gancho de consulta. Comencemos con la devolución de llamada de mutación.
const {client, setRefetchMountedOperations, user} = useContext(wunderGraphContext);
const mutate = useCallback(async (args?: InternalMutationArgsWithInput<Input>): Promise<MutationResult<Data>> => {
if (mutation.requiresAuthentication && user === null) {
return {status: "requires_authentication"}
}
setResult({status: "loading"});
const result = await client.mutate(mutation, args);
setResult(result as any);
if (result.status === "ok" && args?.refetchMountedOperationsOnSuccess === true) {
setRefetchMountedOperations(prev => prev + 1);
}
return result as any;
}, [user]);
Nuestro objetivo es invalidar todas las consultas montadas actualmente cuando una mutación es exitosa. Podemos hacerlo introduciendo otro objeto de estado global que se almacena y propaga a través del contexto React. Llamamos a este objeto de estado "refetchMountedOperationsOnSuccess", que es un contador simple. En caso de que nuestra devolución de llamada de mutación sea exitosa, queremos incrementar el contador. Esto debería ser suficiente para invalidar todas las consultas montadas actualmente.
El segundo paso es cambiar el gancho de consulta.
const {ssrCache, client, isWindowFocused, refetchMountedOperations, user} = useContext(wunderGraphContext);
useEffect(() => {
if (queryResult?.status === "lazy" || queryResult?.status === "none") {
return;
}
setInvalidate(prev => prev + 1);
}, [refetchMountedOperations]);
Ya debería estar familiarizado con el contador "invalidar". Ahora estamos agregando otro efecto para manejar el incremento del contador "refetchMountedOperations" que se inyectó desde el contexto. Quizás se pregunte por qué volvemos antes si el estado es "perezoso" o "ninguno".
En el caso de "lazy", sabemos que esta consulta aún no se ejecutó, y el desarrollador tiene la intención de ejecutarla solo cuando se active manualmente. Por lo tanto, nos saltamos las consultas perezosas y esperamos hasta que se activen manualmente.
En caso de "ninguno", se aplica la misma regla. Esto podría suceder, por ejemplo, si una consulta solo se procesa en el lado del servidor, pero hemos navegado a la página actual a través de la navegación del lado del cliente. En tal caso, no hay nada que podamos "invalidar", ya que la consulta aún no se ha ejecutado. Tampoco queremos desencadenar por accidente consultas que aún no se ejecutaron a través de un efecto secundario de mutación.
¿Quieres experimentar esto en acción? Dirígete a la página Refetch Mounted Operations on Mutation Success .
¡Frio! Hemos terminado con consultas y mutaciones. A continuación, veremos la implementación de ganchos para suscripciones.
Para implementar suscripciones, tenemos que crear un nuevo gancho dedicado:
function useSubscriptionContextWrapper<Input, Data, Role>(wunderGraphContext: Context<WunderGraphContextProperties<Role>>, subscription: SubscriptionProps, args?: InternalSubscriptionArgsWithInput<Input>): {
result: SubscriptionResult<Data>;
} {
const {ssrCache, client} = useContext(wunderGraphContext);
const cacheKey = client.cacheKey(subscription, args);
const [invalidate, setInvalidate] = useState<number>(0);
const [subscriptionResult, setSubscriptionResult] = useState<SubscriptionResult<Data> | undefined>(ssrCache[cacheKey] as SubscriptionResult<Data> || {status: "none"});
useEffect(() => {
if (subscriptionResult?.status === "ok") {
setSubscriptionResult({...subscriptionResult, streamState: "restarting"});
} else {
setSubscriptionResult({status: "loading"});
}
const abort = new AbortController();
client.subscribe(subscription, (response: SubscriptionResult<Data>) => {
setSubscriptionResult(response as any);
}, {
...args,
abortSignal: abort.signal
});
return () => {
abort.abort();
}
}, [invalidate]);
return {
result: subscriptionResult as SubscriptionResult<Data>
}
}
La implementación de este enlace es similar al enlace de consulta. Se activa automáticamente cuando se monta el componente envolvente, por lo que estamos usando el gancho "useEffect" nuevamente.
Es importante pasar una señal de cancelación al cliente para asegurarse de que la suscripción se cancela cuando se desmonta el componente. Además, queremos cancelar y reiniciar la suscripción cuando se incremente el contador de invalidaciones, similar al gancho de consulta.
Hemos omitido la autenticación por brevedad en este punto, pero puede suponer que es muy similar al gancho de consulta.
¿Quieres jugar con el ejemplo? Dirígete a la página de suscripción del lado del cliente .
Sin embargo, una cosa a tener en cuenta es que las suscripciones se comportan de manera diferente a las consultas. Las suscripciones son un flujo de datos que se actualiza continuamente. Esto significa que tenemos que pensar cuánto tiempo queremos mantener abierta la suscripción. ¿Debe permanecer abierto para siempre? ¿O podría darse el caso de que queramos parar y reanudar la suscripción?
Uno de esos casos es cuando el usuario desenfoca la ventana, lo que significa que ya no está usando activamente la aplicación.
Para detener la suscripción cuando el usuario desenfoca la ventana, debemos extender el gancho de suscripción:
function useSubscriptionContextWrapper<Input, Data, Role>(wunderGraphContext: Context<WunderGraphContextProperties<Role>>, subscription: SubscriptionProps, args?: InternalSubscriptionArgsWithInput<Input>): {
result: SubscriptionResult<Data>;
} {
const {ssrCache, client, isWindowFocused, refetchMountedOperations, user} = useContext(wunderGraphContext);
const isServer = typeof window === 'undefined';
const ssrEnabled = args?.disableSSR !== true;
const cacheKey = client.cacheKey(subscription, args);
const [stop, setStop] = useState(false);
const [invalidate, setInvalidate] = useState<number>(0);
const [stopOnWindowBlur] = useState(args?.stopOnWindowBlur === true);
const [subscriptionResult, setSubscriptionResult] = useState<SubscriptionResult<Data> | undefined>(ssrCache[cacheKey] as SubscriptionResult<Data> || {status: "none"});
useEffect(() => {
if (stop) {
if (subscriptionResult?.status === "ok") {
setSubscriptionResult({...subscriptionResult, streamState: "stopped"});
} else {
setSubscriptionResult({status: "none"});
}
return;
}
if (subscriptionResult?.status === "ok") {
setSubscriptionResult({...subscriptionResult, streamState: "restarting"});
} else {
setSubscriptionResult({status: "loading"});
}
const abort = new AbortController();
client.subscribe(subscription, (response: SubscriptionResult<Data>) => {
setSubscriptionResult(response as any);
}, {
...args,
abortSignal: abort.signal
});
return () => {
abort.abort();
}
}, [stop, refetchMountedOperations, invalidate, user]);
useEffect(() => {
if (!stopOnWindowBlur) {
return
}
if (isWindowFocused === "focused") {
setStop(false);
}
if (isWindowFocused === "blurred") {
setStop(true);
}
}, [stopOnWindowBlur, isWindowFocused]);
return {
result: subscriptionResult as SubscriptionResult<Data>
}
}
Para que esto funcione, introducimos una nueva variable con estado llamada "stop". El estado predeterminado será falso, pero cuando el usuario desenfoca la ventana, estableceremos el estado en verdadero. Si vuelven a entrar en la ventana (foco), estableceremos el estado de nuevo en falso. Si el desarrollador establece "stopOnWindowBlur" en falso, lo ignoraremos, lo que se puede configurar en el objeto "args" de las suscripciones.
Además, tenemos que agregar la variable de parada a las dependencias de suscripción. ¡Eso es todo! Es muy útil que hayamos manejado los eventos de la ventana globalmente, esto hace que todos los demás ganchos sean mucho más fáciles de implementar.
La mejor manera de experimentar la implementación es abrir la página de Suscripción del lado del cliente y mirar atentamente la pestaña de red en la consola de Chrome DevTools (o similar si está usando otro navegador).
Volviendo a uno de los problemas que hemos descrito inicialmente, todavía tenemos que dar una respuesta a la pregunta de cómo podemos implementar la representación del lado del servidor para las suscripciones, haciendo que el gancho de suscripciones sea "universal".
Quizás esté pensando que la representación del lado del servidor no es posible para las suscripciones. Quiero decir, ¿cómo debe renderizar el servidor un flujo de datos?
Si es un lector habitual de este blog, es posible que conozca nuestra Implementación de suscripción. Como describimos en otro blog , implementamos las suscripciones de GraphQL de una manera que es compatible con EventSource (SSE), así como con la API Fetch.
También hemos agregado una bandera especial a la implementación. El cliente puede establecer el parámetro de consulta "wg_subscribe_once" en verdadero. Lo que esto significa es que una suscripción, con este indicador establecido, es esencialmente una consulta.
Aquí está la implementación del cliente para obtener una consulta:
const params = this.queryString({
wg_variables: args?.input,
wg_api_hash: this.applicationHash,
wg_subscribe_once: args?.subscribeOnce,
});
const headers: Headers = {
...this.extraHeaders,
Accept: "application/json",
"WG-SDK-Version": this.sdkVersion,
};
const defaultOrCustomFetch = this.customFetch || globalThis.fetch;
const url = this.baseURL + "/" + this.applicationPath + "/operations/" + query.operationName + params;
const response = await defaultOrCustomFetch(url,
{
headers,
method: 'GET',
credentials: "include",
mode: "cors",
}
);
Tomamos las variables, un hash de la configuración y el indicador subscribeOnce y los codificamos en la cadena de consulta. Si se establece suscribirse una vez, está claro para el servidor que solo queremos el primer resultado de la suscripción.
Para brindarle una imagen completa, veamos también la implementación de las suscripciones del lado del cliente:
private subscribeWithSSE = <S extends SubscriptionProps, Input, Data>(subscription: S, cb: (response: SubscriptionResult<Data>) => void, args?: InternalSubscriptionArgs) => {
(async () => {
try {
const params = this.queryString({
wg_variables: args?.input,
wg_live: subscription.isLiveQuery ? true : undefined,
wg_sse: true,
wg_sdk_version: this.sdkVersion,
});
const url = this.baseURL + "/" + this.applicationPath + "/operations/" + subscription.operationName + params;
const eventSource = new EventSource(url, {
withCredentials: true,
});
eventSource.addEventListener('message', ev => {
const responseJSON = JSON.parse(ev.data);
// omitted for brevity
if (responseJSON.data) {
cb({
status: "ok",
streamState: "streaming",
data: responseJSON.data,
});
}
});
if (args?.abortSignal) {
args.abortSignal.addEventListener("abort", () => eventSource.close());
}
} catch (e: any) {
// omitted for brevity
}
})();
};
La implementación del cliente de suscripción es similar al cliente de consulta, excepto que usamos la API de EventSource con una devolución de llamada. Si EventSource no está disponible, recurrimos a Fetch API, pero mantendré la implementación fuera de la publicación del blog, ya que no agrega mucho valor adicional.
Lo único importante que debe quitar de esto es que agregamos un oyente a la señal de cancelación. Si el componente adjunto se desmonta o invalida, activará el evento de cancelación, que cerrará EventSource.
Tenga en cuenta que si estamos haciendo un trabajo asíncrono de cualquier tipo, siempre debemos asegurarnos de que manejamos la cancelación correctamente, de lo contrario, podríamos terminar con una pérdida de memoria.
Bien, ahora conoce la implementación del cliente de suscripción. Envolvamos al cliente con ganchos de suscripción fáciles de usar que se pueden usar tanto en el cliente como en el servidor.
const {ssrCache, client, isWindowFocused, refetchMountedOperations, user} = useContext(wunderGraphContext);
const isServer = typeof window === 'undefined';
const ssrEnabled = args?.disableSSR !== true;
const cacheKey = client.cacheKey(subscription, args);
if (isServer) {
if (ssrEnabled) {
if (ssrCache[cacheKey]) {
return {
result: ssrCache[cacheKey] as SubscriptionResult<Data>
}
}
const promise = client.query(subscription, {...args, subscribeOnce: true});
ssrCache[cacheKey] = promise;
throw promise;
} else {
ssrCache[cacheKey] = {
status: "none",
}
return {
result: ssrCache[cacheKey] as SubscriptionResult<Data>
}
}
}
De manera similar al enlace useQuery, agregamos una rama de código para la representación del lado del servidor. Si estamos en el servidor y aún no tenemos ningún dato, hacemos una solicitud de "consulta" con el indicador subscribeOnce establecido en verdadero. Como se describió anteriormente, una suscripción con el indicador subscribeOnce establecido en verdadero, solo devolverá el primer resultado, por lo que se comporta como una consulta. Es por eso que usamos client.query()
en lugar de client.subscribe()
.
Algunos comentarios en la publicación del blog sobre nuestra implementación de suscripción indicaron que no es tan importante hacer que las suscripciones sean sin estado. Espero que en este punto quede claro por qué hemos ido por este camino. La compatibilidad con Fetch acaba de aterrizar en NodeJS, e incluso antes de eso, hemos tenido node-fetch como un polyfill. Definitivamente sería posible iniciar suscripciones en el servidor usando WebSockets, pero en última instancia, creo que es mucho más fácil simplemente usar la API Fetch y no tener que preocuparse por las conexiones de WebSocket en el servidor.
La mejor manera de jugar con esta implementación es ir a la página de suscripción universal . Cuando actualice la página, eche un vistazo a la "vista previa" de la primera solicitud. Verá que la página vendrá renderizada por el servidor en comparación con la suscripción del lado del cliente. Una vez que el cliente se rehidrate, iniciará una suscripción por sí mismo para mantener actualizada la interfaz de usuario.
Eso fue mucho trabajo, pero aún no hemos terminado. Las suscripciones también deben protegerse mediante la autenticación, agreguemos algo de lógica al gancho de suscripción.
Notarás que es muy similar a un gancho de consulta normal.
const {ssrCache, client, isWindowFocused, refetchMountedOperations, user} = useContext(wunderGraphContext);
const [subscriptionResult, setSubscriptionResult] = useState<SubscriptionResult<Data> | undefined>(ssrCache[cacheKey] as SubscriptionResult<Data> || {status: "none"});
useEffect(() => {
if (subscription.requiresAuthentication && user === null) {
setSubscriptionResult({
status: "requires_authentication",
});
return;
}
if (stop) {
if (subscriptionResult?.status === "ok") {
setSubscriptionResult({...subscriptionResult, streamState: "stopped"});
} else {
setSubscriptionResult({status: "none"});
}
return;
}
if (subscriptionResult?.status === "ok") {
setSubscriptionResult({...subscriptionResult, streamState: "restarting"});
} else {
setSubscriptionResult({status: "loading"});
}
const abort = new AbortController();
client.subscribe(subscription, (response: SubscriptionResult<Data>) => {
setSubscriptionResult(response as any);
}, {
...args,
abortSignal: abort.signal
});
return () => {
abort.abort();
}
}, [stop, refetchMountedOperations, invalidate, user]);
Primero, tenemos que agregar el usuario como una dependencia para el efecto. Esto hará que el efecto se dispare cada vez que cambie el usuario. Luego, debemos verificar los metadatos de la suscripción y ver si requiere autenticación. Si lo hace, comprobamos si el usuario está logueado. Si el usuario está logueado, continuamos con la suscripción. Si el usuario no ha iniciado sesión, establecemos el resultado de la suscripción en "requires_authentication".
¡Eso es todo! ¡Suscripciones universales con reconocimiento de autenticación completadas! Echemos un vistazo a nuestro resultado final:
const ProtectedSubscription = () => {
const {login,logout,user} = useWunderGraph();
const data = useSubscription.ProtectedPriceUpdates();
return (
<div>
<p>{JSON.stringify(user)}</p>
<p style={{height: "8vh"}}>{JSON.stringify(data)}</p>
<button onClick={() => login(AuthProviders.github)}>Login</button>
<button onClick={() => logout()}>Logout</button>
</div>
)
}
export default withWunderGraph(ProtectedSubscription);
¿No es genial cómo podemos ocultar tanta complejidad detrás de una API simple? Todas estas cosas, como la autenticación, el enfoque y el desenfoque de la ventana, la representación del lado del servidor, la representación del lado del cliente, el paso de datos del servidor al cliente, la rehidratación adecuada del cliente, todo lo manejamos nosotros.
Además de eso, el cliente usa principalmente genéricos y está envuelto por una pequeña capa de código generado, lo que hace que todo el cliente sea completamente seguro. La seguridad tipográfica era uno de nuestros requisitos, si recuerdas.
Algunos clientes de API "pueden" tener seguridad de tipos. Otros le permiten agregar algún código adicional para que sean de tipo seguro. Con nuestro enfoque, un cliente genérico más tipos generados automáticamente, el cliente siempre tiene seguridad de tipos.
Es un manifiesto para nosotros que, hasta ahora, nadie nos ha pedido que agreguemos un cliente de JavaScript "puro". Nuestros usuarios parecen aceptar y apreciar que todo es seguro desde el primer momento. Creemos que la seguridad de tipos ayuda a los desarrolladores a cometer menos errores y a comprender mejor su código.
¿Quieres jugar tú mismo con suscripciones universales protegidas? Consulte la página de suscripción protegida de la demostración. No olvide consultar Chrome DevTools y la pestaña de red para obtener la mejor información.
Finalmente, hemos terminado con las suscripciones. Faltan dos patrones más y hemos terminado por completo.
El último patrón que vamos a cubrir es Live Queries. Las consultas en vivo son similares a las suscripciones en la forma en que se comportan en el lado del cliente. Donde difieren es en el lado del servidor.
Primero analicemos cómo funcionan las consultas en vivo en el servidor y por qué son útiles. Si un cliente se "suscribe" a una consulta en vivo, el servidor comenzará a sondear el servidor de origen en busca de cambios. Lo hará en un intervalo configurable, por ejemplo, cada segundo. Cuando el servidor recibe un cambio, analizará los datos y los comparará con el hash del último cambio. Si los hashes son diferentes, el servidor enviará los nuevos datos al cliente. Si los hashes son los mismos, sabemos que nada cambió, por lo que no enviamos nada al cliente.
¿Por qué y cuándo son útiles las consultas en vivo? En primer lugar, gran parte de la infraestructura existente no admite suscripciones. Agregar consultas en vivo en el nivel de la puerta de enlace significa que puede agregar capacidades de "tiempo real" a su infraestructura existente. Podría tener un backend de PHP heredado que ya no quiera tocar. Agregue consultas en vivo encima y su interfaz podrá recibir actualizaciones en tiempo real.
Quizás se esté preguntando por qué no simplemente hacer el sondeo desde el lado del cliente. El sondeo del lado del cliente podría generar muchas solicitudes al servidor. Imagínese si 10.000 clientes hacen una solicitud por segundo. Eso es 10.000 solicitudes por segundo. ¿Crees que tu servidor PHP heredado puede manejar ese tipo de carga?
¿Cómo pueden ayudar las consultas en vivo? 10.000 clientes se conectan a la puerta de enlace api y se suscriben a una consulta en vivo. Luego, la puerta de enlace puede agrupar todas las solicitudes, ya que esencialmente solicitan los mismos datos y realizar una sola solicitud al origen.
Al usar consultas en vivo, podemos reducir la cantidad de solicitudes al servidor de origen, según la cantidad de "flujos" que se usen.
Entonces, ¿cómo podemos implementar consultas en vivo en el cliente?
Eche un vistazo al envoltorio "generado" del cliente genérico para una de nuestras operaciones:
CountryWeather: (args: SubscriptionArgsWithInput<CountryWeatherInput>) =>
hooks.useSubscriptionWithInput<CountryWeatherInput, CountryWeatherResponseData, Role>(WunderGraphContext, {
operationName: "CountryWeather",
isLiveQuery: true,
requiresAuthentication: false,
})(args)
Mirando este ejemplo, puede notar algunas cosas. Primero, estamos usando el useSubscriptionWithInput
gancho. Esto indica que en realidad no tenemos que distinguir entre una suscripción y una consulta en vivo, al menos no desde la perspectiva del lado del cliente. La única diferencia es que estamos configurando la isLiveQuery
bandera en true
. Para las suscripciones, usamos el mismo enlace, pero establecemos la isLiveQuery
marca en false
.
Como ya implementamos el enlace de suscripción anterior, no se requiere código adicional para que las consultas en vivo funcionen.
Consulte la página de consulta en vivo de la demostración. Una cosa que puede notar es que este ejemplo tiene el desagradable parpadeo nuevamente, eso se debe a que no lo estamos renderizando del lado del servidor.
El patrón final y último que vamos a cubrir es Universal Live Queries. Las consultas en vivo universales son similares a las suscripciones, solo que más simples desde la perspectiva del lado del servidor. Para que el servidor inicie una suscripción, tiene que abrir una conexión WebSocket con el servidor de origen, hacer el protocolo de enlace, suscribirse, etc. Si necesitamos suscribirnos una vez con una consulta en vivo, simplemente estamos "sondeando" una vez , lo que significa que solo estamos haciendo una sola solicitud. Por lo tanto, las consultas en vivo son en realidad un poco más rápidas de iniciar en comparación con las suscripciones, al menos en la solicitud inicial.
¿Cómo podemos usarlos? Veamos un ejemplo de la demostración:
const UniversalLiveQuery = () => {
const data = useLiveQuery.CountryWeather({
input: {
code: "DE",
},
});
return (
<p>{JSON.stringify(data)}</p>
)
}
export default withWunderGraph(UniversalLiveQuery);
Eso es todo, ese es su flujo de datos meteorológicos para la capital de Alemania, Berlín, que se actualiza cada segundo.
Quizás se pregunte cómo obtuvimos los datos en primer lugar. Veamos la definición de la CountryWeather
operación:
query ($capital: String! @internal $code: ID!) {
countries_country(code: $code){
code
name
capital @export(as: "capital")
weather: _join @transform(get: "weather_getCityByName.weather") {
weather_getCityByName(name: $capital){
weather {
temperature {
actual
}
summary {
title
description
}
}
}
}
}
}
En realidad estamos uniendo datos de dos servicios dispares. Primero, estamos usando una API de países para obtener la capital de un país. Exportamos el campo a la variable capital
interna . $capital
Luego, usamos el _join
campo para combinar los datos del país con una API meteorológica. Finalmente, aplicamos la @transform
directiva para aplanar un poco la respuesta.
Es una consulta GraphQL normal y válida. En combinación con el patrón de consulta en vivo, ahora podemos transmitir en vivo el clima de cualquier capital de cualquier país. Genial, ¿no?
Al igual que todos los demás patrones, este también se puede probar en la demostración. ¡ Dirígete a la página de consulta universal en vivo y juega!
¡Eso es todo! ¡Hemos terminado! Espero que haya aprendido cómo puede crear ganchos de obtención de datos universales y conscientes de la autenticación.
Antes de que lleguemos al final de esta publicación, me gustaría ver enfoques y herramientas alternativos para implementar ganchos de obtención de datos.
Una de las principales desventajas de utilizar la representación del lado del servidor es que el cliente tiene que esperar hasta que el servidor haya terminado de representar la página. Dependiendo de la complejidad de la página, esto puede llevar un tiempo, especialmente si tiene que realizar muchas solicitudes encadenadas para obtener todos los datos necesarios para la página.
Una solución a este problema es generar estáticamente la página en el servidor. NextJS le permite implementar una getStaticProps
función asíncrona en la parte superior de cada página. Esta función se llama en el momento de la creación y es responsable de obtener todos los datos necesarios para la página. Si, al mismo tiempo, no adjunta una función getInitialProps
o getServerSideProps
a la página, NextJS considera que esta página es estática, lo que significa que no se requerirá ningún proceso de NodeJS para representar la página. En este escenario, la página se renderizará previamente en el momento de la compilación, lo que permitirá que una CDN la almacene en caché.
Esta forma de renderizar hace que la aplicación sea extremadamente rápida y fácil de alojar, pero también tiene inconvenientes.
Por un lado, una página estática no es específica del usuario. Eso es porque en tiempo de construcción, no hay contexto del usuario. Sin embargo, esto no es un problema para las páginas públicas. Es solo que no puede usar páginas específicas de usuario como tableros de esta manera.
Una compensación que se puede hacer es renderizar la página de forma estática y agregar contenido específico del usuario en el lado del cliente. Sin embargo, esto siempre generará parpadeo en el cliente, ya que la página se actualizará muy poco tiempo después del renderizado inicial. Por lo tanto, si está creando una aplicación que requiere que el usuario esté autenticado, es posible que desee utilizar la representación del lado del servidor en su lugar.
El segundo inconveniente de la generación de sitios estáticos es que el contenido puede quedar obsoleto si los datos subyacentes cambian. En ese caso, es posible que desee reconstruir la página. Sin embargo, la reconstrucción de toda la página puede llevar mucho tiempo y puede ser innecesaria si solo se necesita reconstruir unas pocas páginas. Afortunadamente, hay una solución a este problema: la regeneración estática incremental.
La regeneración estática incremental le permite invalidar páginas individuales y volver a procesarlas a pedido. Esto le brinda la ventaja de rendimiento de un sitio estático, pero elimina el problema del contenido obsoleto.
Dicho esto, esto todavía no resuelve el problema con la autenticación, pero no creo que esto sea de lo que se trata la generación de sitios estáticos.
Por nuestra parte, actualmente estamos analizando patrones en los que el resultado de una mutación podría desencadenar automáticamente una reconstrucción de página mediante ISR. Idealmente, esto podría ser algo que funcione de forma declarativa, sin tener que implementar una lógica personalizada.
Un problema con el que se puede encontrar con la representación del lado del servidor (pero también del lado del cliente) es que al atravesar el árbol de componentes, el servidor puede tener que crear una enorme cascada de consultas que dependen unas de otras. Si los componentes secundarios dependen de los datos de sus padres, es posible que se encuentre fácilmente con el problema N+1.
N+1 en este caso significa que obtiene una matriz de datos en un componente raíz y luego, para cada uno de los elementos de la matriz, tendrá que activar una consulta adicional en un componente secundario.
Tenga en cuenta que este problema no es específico del uso de GraphQL. GraphQL en realidad tiene una solución para resolverlo, mientras que las API REST sufren el mismo problema. La solución es usar fragmentos de GraphQL con un cliente que los admita adecuadamente.
Los creadores de GraphQL, Facebook/Meta, han creado una solución para este problema, se llama Relay Client.
Relay Client es una biblioteca que le permite especificar sus "Requisitos de datos" junto con los componentes a través de fragmentos de GraphQL. Aquí hay un ejemplo de cómo podría verse esto:
import type {UserComponent_user$key} from 'UserComponent_user.graphql';
const React = require('React');
const {graphql, useFragment} = require('react-relay');
type Props = {
user: UserComponent_user$key,
};
function UserComponent(props: Props) {
const data = useFragment(
graphql`
fragment UserComponent_user on User {
name
profile_picture(scale: 2) {
uri
}
}
`,
props.user,
);
return (
<>
<h1>{data.name}</h1>
<div>
<img src={data.profile_picture?.uri} />
</div>
</>
);
}
Si este fuera un componente anidado, el fragmento nos permite elevar nuestros requisitos de datos hasta el componente raíz. Esto significa que el componente raíz será capaz de obtener los datos de sus elementos secundarios, manteniendo la definición de requisitos de datos en los componentes secundarios.
Los fragmentos permiten un acoplamiento flexible entre los componentes principales y secundarios, al tiempo que permiten un proceso de obtención de datos más eficiente. Para muchos desarrolladores, esta es la razón real por la que usan GraphQL. No es que usen GraphQL porque quieran usar el lenguaje de consulta, es porque quieren aprovechar el poder del cliente de retransmisión.
Para nosotros, el Cliente de Relay es una gran fuente de inspiración. De hecho, creo que usar Relay es demasiado difícil. En nuestra próxima iteración, buscamos adoptar el enfoque de "elevación de fragmentos", pero nuestro objetivo es que sea más fácil de usar que el cliente de retransmisión.
Otro desarrollo que está ocurriendo en el mundo de React es la creación de React Suspense. Como ha visto anteriormente, ya estamos usando Suspense en el servidor. Al "lanzar" una promesa, podemos suspender la representación de un componente hasta que se resuelva la promesa. Esa es una excelente manera de manejar la obtención de datos asincrónicos en el servidor.
Sin embargo, también puede aplicar esta técnica en el cliente. El uso de Suspense en el cliente nos permite "renderizar mientras se obtiene" de una manera muy eficiente. Además, los clientes que admiten Suspense permiten una API más elegante para enlaces de obtención de datos. En lugar de tener que manejar estados de "carga" o "error" dentro del componente, el suspenso "empujará" estos estados al siguiente "límite de error" y los manejará allí. Este enfoque hace que el código dentro del componente sea mucho más legible, ya que solo maneja el "camino feliz".
Como ya admitimos Suspense en el servidor, puede estar seguro de que también agregaremos soporte al cliente en el futuro. Solo queremos descubrir la forma más idiomática de apoyar tanto a un cliente de suspenso como a uno que no lo es. De esta manera, los usuarios obtienen la libertad de elegir el estilo de programación que prefieran.
No somos los únicos que intentamos mejorar la experiencia de obtención de datos en NextJS. Por lo tanto, echemos un vistazo rápido a otras tecnologías y cómo se comparan con el enfoque que estamos proponiendo.
De hecho, nos hemos inspirado mucho en swr. Si observa los patrones que hemos implementado, verá que swr realmente nos ayudó a definir una excelente API de obtención de datos.
Hay algunas cosas en las que nuestro enfoque difiere del swr que vale la pena mencionar.
SWR es mucho más flexible y fácil de adoptar porque puede usarlo con cualquier backend. El enfoque que hemos adoptado, especialmente la forma en que manejamos la autenticación, requiere que también ejecute un backend de WunderGraph que proporcione la API que esperamos.
Por ejemplo, si está utilizando el cliente WunderGraph, esperamos que el backend sea una parte dependiente de OpenID Connect. El cliente swr, por otro lado, no hace tales suposiciones.
Personalmente, creo que con una biblioteca como swr, eventualmente obtendrá un resultado similar al que obtendría si estuviera usando el cliente WunderGraph en primer lugar. Es solo que ahora está manteniendo más código ya que tuvo que agregar lógica de autenticación.
La otra gran diferencia es la representación del lado del servidor. WunderGraph está cuidadosamente diseñado para eliminar cualquier parpadeo innecesario al cargar una aplicación que requiere autenticación. Los documentos de swr explican que esto no es un problema y que los usuarios están de acuerdo con cargar spinners en los tableros.
Creo que podemos hacerlo mejor que eso. Sé de paneles SaaS que tardan 15 segundos o más en cargar todos los componentes, incluido el contenido. Durante este período de tiempo, la interfaz de usuario no se puede usar en absoluto, porque sigue "moviendo" todo el contenido en el lugar correcto.
¿Por qué no podemos renderizar previamente todo el tablero y luego rehidratar al cliente? Si el HTML se representa de la manera correcta, se debe poder hacer clic en los enlaces incluso antes de que se cargue el cliente de JavaScript.
Si todo su "backend" cabe en el directorio "/api" de su aplicación NextJS, su mejor opción probablemente sea usar la biblioteca "swr". Combinado con NextAuthJS, esto puede ser una muy buena combinación.
Si, en cambio, está creando servicios dedicados para implementar API, un enfoque de "backend para frontend", como el que proponemos con WunderGraph, podría ser una mejor opción, ya que podemos eliminar muchos cierres de sesión repetitivos. de sus servicios y en el middleware.
Hablando de NextAuthJS, ¿por qué no simplemente agregar la autenticación directamente en su aplicación NextJS? La biblioteca está diseñada para resolver exactamente este problema, agregando autenticación a su aplicación NextJS con un mínimo esfuerzo.
Desde una perspectiva técnica, NextAuthJS sigue patrones similares a WunderGraph. Solo hay algunas diferencias en términos de la arquitectura general.
Si está creando una aplicación que nunca escalará más allá de un solo sitio web, probablemente pueda usar NextAuthJS. Sin embargo, si planea usar varios sitios web, herramientas cli, aplicaciones nativas o incluso conectar un backend, es mejor que use un enfoque diferente.
Déjame explicarte por qué.
La forma en que se implementa NextAuthJS es que en realidad se convierte en el "Emisor" del flujo de autenticación. Dicho esto, no es un emisor compatible con OpenID Connect, es una implementación personalizada. Entonces, si bien es fácil comenzar, en realidad está agregando una gran cantidad de deuda técnica al principio.
Supongamos que le gustaría agregar otro tablero o una herramienta cli o conectar un backend a sus API. Si estaba usando un emisor compatible con OpenID Connect, ya hay un flujo implementado para varios escenarios diferentes. Además, este proveedor de OpenID Connect solo está ligeramente acoplado a su aplicación NextJS. Hacer que su propia aplicación sea el emisor significa que debe volver a implementar y modificar su aplicación "frontend", cada vez que desee modificar el flujo de autenticación. Tampoco podrá usar flujos de autenticación estandarizados como el flujo de código con pkce o el flujo del dispositivo.
La autenticación debe gestionarse fuera de la propia aplicación. Recientemente anunciamos nuestra asociación con Cloud IAM , lo que hace que la configuración de un proveedor de OpenID Connect con WunderGraph como la parte de confianza sea cuestión de minutos.
Espero que se lo pongamos lo suficientemente fácil para que no tenga que crear sus propios flujos de autenticación.
La capa de obtención de datos y los ganchos son en realidad muy parecidos a WunderGraph. Creo que incluso estamos usando el mismo enfoque para la representación del lado del servidor en NextJS.
El trpc obviamente tiene muy poco que ver con GraphQL, en comparación con WunderGraph. Su historia sobre la autenticación tampoco es tan completa como WunderGraph.
Dicho esto, creo que Alex ha hecho un gran trabajo al construir trpc. Es menos obstinado que WunderGraph, lo que lo convierte en una excelente opción para diferentes escenarios.
Según tengo entendido, trpc funciona mejor cuando tanto el backend como el frontend usan TypeScript. WunderGraph toma un camino diferente. El término medio común para definir el contrato entre el cliente y el servidor es JSON-RPC, definido mediante JSON Schema. En lugar de simplemente importar los tipos de servidor al cliente, debe pasar por un proceso de generación de código con WunderGraph.
Esto significa que la configuración es un poco más compleja, pero no solo podemos admitir TypeScript como entorno de destino, sino cualquier otro lenguaje o tiempo de ejecución que admita JSON a través de HTTP.
Hay muchos otros clientes de GraphQL, como Apollo Client, urql y graphql-request. Lo que todos ellos tienen en común es que no suelen utilizar JSON-RPC como transporte.
Probablemente haya escrito esto en varias publicaciones de blog antes, pero enviar solicitudes de lectura a través de HTTP POST simplemente rompe Internet. Si no está cambiando las operaciones GraphQL, como el 99 % de todas las aplicaciones que usan un paso de compilación/transpilación, ¿por qué usar un cliente GraphQL que hace esto?
Clientes, navegadores, servidores de caché, servidores proxy y CDN, todos entienden los encabezados de control de caché y las etiquetas electrónicas. El popular cliente de obtención de datos NextJS "swr" tiene su nombre por una razón, porque swr significa "obsoleto mientras se revalida", que no es más que el patrón que aprovecha ETags para una invalidación de caché eficiente.
GraphQL es una gran abstracción para definir dependencias de datos. Pero cuando se trata de implementar aplicaciones a escala web, debemos aprovechar la infraestructura existente de la web. Lo que esto significa es esto: GraphQL es excelente durante el desarrollo, pero en la producción, deberíamos aprovechar los principios de REST tanto como podamos.
Crear buenos ganchos de obtención de datos para NextJS y React en general es un desafío. También hemos discutido que estamos llegando a soluciones algo diferentes si tomamos en cuenta la autenticación desde el principio. Personalmente, creo que agregar autenticación directamente en la capa API en ambos extremos, backend y frontend, hace que el enfoque sea mucho más limpio. Otro aspecto a tener en cuenta es dónde colocar la lógica de autenticación. Idealmente, no lo está implementando usted mismo, pero puede confiar en una implementación adecuada. Combinar OpenID Connect como emisor con una parte dependiente en su back-end-for-frontend (BFF) es una excelente manera de mantener las cosas desacopladas pero aún muy controlables.
Nuestro BFF todavía está creando y validando cookies, pero no es la fuente de la verdad. Siempre estamos delegando a Keycloak. Lo bueno de esta configuración es que puede cambiar fácilmente Keycloak por otra implementación, esa es la belleza de confiar en interfaces en lugar de implementaciones concretas.
Finalmente, espero poder convencerlo de que más tableros (SaaS) deberían adoptar la representación del lado del servidor. NextJS y WunderGraph hacen que sea tan fácil de implementar que vale la pena intentarlo.
Una vez más, si está interesado en jugar con una demostración, aquí está el repositorio: https://github.com/wundergraph/wundergraph-demo
1598839687
If you are undertaking a mobile app development for your start-up or enterprise, you are likely wondering whether to use React Native. As a popular development framework, React Native helps you to develop near-native mobile apps. However, you are probably also wondering how close you can get to a native app by using React Native. How native is React Native?
In the article, we discuss the similarities between native mobile development and development using React Native. We also touch upon where they differ and how to bridge the gaps. Read on.
Let’s briefly set the context first. We will briefly touch upon what React Native is and how it differs from earlier hybrid frameworks.
React Native is a popular JavaScript framework that Facebook has created. You can use this open-source framework to code natively rendering Android and iOS mobile apps. You can use it to develop web apps too.
Facebook has developed React Native based on React, its JavaScript library. The first release of React Native came in March 2015. At the time of writing this article, the latest stable release of React Native is 0.62.0, and it was released in March 2020.
Although relatively new, React Native has acquired a high degree of popularity. The “Stack Overflow Developer Survey 2019” report identifies it as the 8th most loved framework. Facebook, Walmart, and Bloomberg are some of the top companies that use React Native.
The popularity of React Native comes from its advantages. Some of its advantages are as follows:
Are you wondering whether React Native is just another of those hybrid frameworks like Ionic or Cordova? It’s not! React Native is fundamentally different from these earlier hybrid frameworks.
React Native is very close to native. Consider the following aspects as described on the React Native website:
Due to these factors, React Native offers many more advantages compared to those earlier hybrid frameworks. We now review them.
#android app #frontend #ios app #mobile app development #benefits of react native #is react native good for mobile app development #native vs #pros and cons of react native #react mobile development #react native development #react native experience #react native framework #react native ios vs android #react native pros and cons #react native vs android #react native vs native #react native vs native performance #react vs native #why react native #why use react native
1652150880
A frontend developer should be able to define what data is needed for a given page, without having to worry about how the data actually gets into the frontend.
That's what a friend of mine recently said in a discussion. Why is there no simple way to universal data fetching in NextJS? To answer this question, let's have a look at the challenges involved with universal data fetching in NextJS.
But first, what actually is universal data fetching?
It's going to cover a lot of ground and will get quite deep into the details.***
If you're expecting a lightweight marketing blog, this article is not for you.
My definition of universal data fetching is that you can put a data-fetching hook anywhere in your application, and it would just work.
This data fetching hook should work everywhere in your application without any additional configuration.
Here's an example, probably the most complicated one,but I'm just too excited to not share it with you.
This is a "universal subscription" hook.
const PriceUpdates = () => {
const data = useSubscription.PriceUpdates();
return (
<div>
<h1>Universal Subscription</h1>
<p>{JSON.stringify(data)}</p>
</div>
)
}
The "PriceUpdates" hook is generated by our frameworkas we've defined a "PriceUpdates.graphql" file in our project. What's special about this hook? You're free to put React Component anywhere in your application. By default, it will server-render the first item from the subscription. The server-rendered HTML will then be sent to the client, alongside with the data.
The client will re-hydrate the application and start a subscription itself. All of this is done without any additional configuration. It works everywhere in your application, hence the name, universal data fetching.
Define the data you need, by writing a GraphQL Operation, and the framework will take care of the rest. Keep in mind that we're not trying to hide the fact that network calls are being made.
What we're doing here is to give frontend developers back their productivity. You shouldn't be worrying about how the data is fetched, how to secure the API layer, what transport to use, etc... It should just work.
If you've been using NextJS for a while, you might be asking what exactly should be hard about data fetching?
In NextJS, you can simply define an endpoint in the "/api" directory, which can then be called by using "swr" or just "fetch".
It's correct that the "Hello, world!" example of fetching data from "/api" is really simple, but scaling an application beyond the first page can quickly overwhelm the developer. Let's look at the main challenges of data fetching in NextJS.
By default, the only place where you can use async functions to load data that is required for server-side-rendering, is at the root of each page. Here's an example from the NextJS documentation:
function Page({ data }) {
// Render data...
}
// This gets called on every request
export async function getServerSideProps() {
// Fetch data from external API
const res = await fetch(`https://.../data`)
const data = await res.json()
// Pass data to the page via props
return { props: { data } }
}
export default Page
Imagine a website with hundreds of pages and components. If you have to define all data dependencies at the root of each page, how do you know what data is really needed before rendering the component tree?
Depending on the data you've loaded for root components, some logic might decide to completely change the child components. I've talked to Developers who have to maintain large NextJS applications.
They have clearly stated that fetching data in "getServerSideProps" doesn't scale well with a large number of pages and components.
Most applications have some sort of authentication mechanism. There might be some content that is publicly available, but what if you want to personalize a website?
There's going to be a need to render different content for different users. When you render user-specific content on the client only, have you noticed this ugly "flickering" effect once data comes in?
If you're only rendering the user-specific content on the client, you'll always get the effect that the page will re-render multiple times until it's ready. Ideally, our data-fetching hooks would be authentication-aware out of the box.
As we've seen in the example above using "getServerSideProps", we need to take additional actions to make our API layer type-safe. Wouldn't it better if the data-fetching hooks were type-safe by default?
So far, I've never seen anyone who applied server-side-rendering in NextJS to subscriptions. But what if you want to server-render a stock price for SEO and performance reasons, but also want to have a client-side subscription to receive updates?
Surely, you could use a Query/GET request on the server, and then add a subscription on the client, but this adds a lot of complexity. There should be a simpler way!
Another question that comes up is what should happen if the user leaves and re-enters the window. Should subscriptions be stopped or continue to stream data?
Depending on the use case and kind of application, you might want to tweak this behaviour, depending on expected user experience and the kind of data you're fetching. Our data-fetching hooks should be able to handle this.
It's quite common that mutations will have side-effects on other data-fetching hooks. E.g. you could have a list of tasks. When you add a new task, you also want to update the list of tasks. Therefore, the data-fetching hooks need to be able to handle these kinds of situations.
Another common pattern is lazy loading. You might want to load data only under certain conditions, e.g. when the user scrolls to the bottom of the page or when the user clicks a button. In such cases, our data-fetching hooks should be able to defer executing the fetch until the data is actually needed.
Another important requirement for data-fetching hooks is to debounce the execution of a Query. This is to avoid unnecessary requests to the server. Imagine a situation where a user is typing a search term in a search box.
Should you really make a request to the server every time the user types a letter? We'll see how we can use debouncing to avoid this and make our data-fetching hooks more performant.
That brings us down to 8 core problems that we need to solve. Let's now discuss 21 patterns and best practices solving these problems.
If you want to follow along and experience these patterns yourself, you can clone this repository and play around. For each pattern, there's a dedicated page in the demo.
Once you've started the demo, you can open your browser and find the patterns overview on
http://localhost:3000/patterns
.
You'll notice that we're using GraphQL to define our data-fetching hooks, but the implementation really is not GraphQL specific. You can apply the same patterns with other API styles like REST, or even with a custom API.
The first pattern we'll look at is the client-side user, it's the foundation to build authentication-aware data-fetching hooks.
Here's the hook to fetch the current user:
useEffect(() => {
if (disableFetchUserClientSide) {
return;
}
const abort = new AbortController();
if (user === null) {
(async () => {
try {
const nextUser = await ctx.client.fetchUser(abort.signal);
if (JSON.stringify(nextUser) === JSON.stringify(user)) {
return;
}
setUser(nextUser);
} catch (e) {
}
})();
}
return () => {
abort.abort();
};
}, [disableFetchUserClientSide]);
Inside our page root, we'll use this hook to fetch the current user (if it was not fetched yet on the server).
It's important to always pass the abort controller to the client, otherwise we might run into memory leaks. The returning arrow function is called when the component containing the hook is unmounted.
You'll notice that we're using this pattern throughout our application to handle potential memory leaks properly. Let's now look into the implementation of "client.fetchUser".
public fetchUser = async (abortSignal?: AbortSignal, revalidate?: boolean): Promise<User<Role> | null> => {
try {
const revalidateTrailer = revalidate === undefined ? "" : "?revalidate=true";
const response = await fetch(this.baseURL + "/" + this.applicationPath + "/auth/cookie/user" + revalidateTrailer, {
headers: {
...this.extraHeaders,
"Content-Type": "application/json",
"WG-SDK-Version": this.sdkVersion,
},
method: "GET",
credentials: "include",
mode: "cors",
signal: abortSignal,
});
if (response.status === 200) {
return response.json();
}
} catch {
}
return null;
};
You'll notice that we're not sending any client credentials, token, or anything else. We're implicitly send the secure, encrypted, http only cookie that was set by the server, which our client has no access to.
For those who don't know, http only cookies are automatically attached to each request if you're on the same domain. If you're using HTTP/2, it's also possible for client and server to apply header compression, which means that the cookie doesn't have to be sent in every request as both client and server can negotiate a map of known header key value pairs on the connection level.
The pattern that we're using behind the scenes to make authentication that simple is called the "Token Handler Pattern". The token handler pattern is the most secure way to handle authentication in modern JavaScript applications.
While very secure, it also allows us to stay agnostic to the identity provider.By applying the token handler pattern, we can easily switch between different identity providers.
That's because our "backend" is acting as an OpenID Connect Relying Party. What's a Relying Party you might ask? It's an application with an OpenID Connect client that outsources the authentication to a third party.
As we're speaking in the context of OpenID Connect, our "backend" is compatible with any service that implements the OpenID Connect protocol. This way, our backend can provide a seamless authentication experience, while developers can choose between different identity providers, like Keycloak, Auth0, Okta, Ping Identity, etc… How does the authentication flow look like from the users' perspective?
The user clicks login.
The frontend redirects the user to the backend (relying party).
The backend redirects the user to the identity provider.
The user authenticates at the identity provider.
If the authentication is successful, the identity provider redirects the user back to the backend.
The backend then exchanges the authorization code for an access and identity token.
The access and identity token are used to set a secure, encrypted, http only cookie on the client.
With the cookie set, the user is redirected back to the frontend.
From now on, when the client calls the fetchUser
method, it will automatically send the cookie to the backend. This way, the frontend always has access to the user's information while logged in. If the user clicks logout, we'll call a function on the backend that will invalidate the cookie.
All this might be a lot to digest, so let's summarize the essential bits. First, you have to tell the backend what Identity providers to work with so that it can act as a Reyling Party.
Once this is done, you're able to initiate the authentication flow from the frontend, fetch the current user from the backend, and logout. If we're wrapping this "fetchUser" call into a useEffect
hook which we place at the root our each page, we'll always know what the current user is.
However, there's a catch. If you open the demo and head over to the client-side-user page, you'll notice that there's a flickering effect after the page is loaded, that's because the fetchUser
call is happening on the client. If you look at Chrome DevTools and open the preview of the page, you'll notice that the page is rendered with the user object set to null
.
You can click the login button to start the login flow. Once complete, refresh the page, and you'll see the flickering effect. Now that you understand the mechanics behind the token handler pattern, let's have a look at how we can remove the flickering on the first page load.
If you want to get rid of the flickering, we have to load the user on the server side so that you can apply server-side rendering. At the same time, we have to somehow get the server-side rendered user to the client. If we miss that second step, the re-hydration of the client will fail as the server-rendered html will differ from the first client-side render.
So, how do we get access to the user object on the server-side? Remember that all we've got is a cookie attached to a domain. Let's say, our backend is running on api.example.com
, and the frontend is running on www.example.com
or example.com
. If there's one important thing you should know about cookies it's that you're allowed to set cookies on parent domains if you're on a subdomain.
This means, once the authentication flow is complete, the backend should NOT set the cookie on the api.example.com
domain. Instead, it should set the cookie to the example.com
domain.
By doing so, the cookie becomes visible to all subdomains of example.com
, including www.example.com
, api.example.com
and example.com
itself.
By the way, this is an excellent pattern to implement single sign on. Have you users login once, and they are authenticated on all subdomains.
WunderGraph automatically sets cookies to the parent domain if the backend is on a subdomain, so you don't have to worry about this. Now, back to getting the user on the server side. In order to get the user on the server side, we have to implement some logic in the getInitialProps
method of our pages.
WunderGraphPage.getInitialProps = async (ctx: NextPageContext) => {
// ... omitted for brevity
const cookieHeader = ctx.req?.headers.cookie;
if (typeof cookieHeader === "string") {
defaultContextProperties.client.setExtraHeaders({
Cookie: cookieHeader,
});
}
let ssrUser: User<Role> | null = null;
if (options?.disableFetchUserServerSide !== true) {
try {
ssrUser = await defaultContextProperties.client.fetchUser();
} catch (e) {
}
}
// ... omitted for brevity
return {...pageProps, ssrCache, user: ssrUser};
The ctx
object of the getInitialProps
function contains the client request including headers.
We can do a "magic trick" so that the "API client", which we create on the server-side, can act on behalf of the user. As both frontend and backend share the same parent domain, we've got access to the cookie that was set by the backend.
So, if we take the cookie header and set it as the Cookie
header of the API client, the API client will be able to act in the context of the user, even on the server-side! We can now fetch the user on the server-side and pass the user object alongside the pageProps to the render function of the page.
Make sure to not miss this last step, otherwise the re-hydration of the client will fail. Alright, we've solved the problem of the flickering, at least when you hit refresh. But what if we've started on a different page and used client-side navigation to get to this page?
Open up the demo and try it out yourself. You'll see that the user object will be set to null
if the user was not loaded on the other page. To solve this problem as well, we have to go one step further and apply the "universal user" pattern.
The universal user pattern is the combination of the two previous patterns.
If we're hitting the page for the first time, load the user on the server-side, if possible, and render the page. On the client-side, we re-hydrate the page with the user object and don't re-fetch it, therefore there's no flickering.
In the second scenario, we're using client-side navigation to get to our page. In this case, we check if the user is already loaded. If the user object is null, we'll try to fetch it.
Great, we've got the universal user pattern in place! But there's another problem that we might face. What happens if the user opens up a second tab or window and clicks the logout button?
Open the universal-user page in the demo in two tabs or windows and try it out yourself.
If you click logout in one tab, then head back to the other tab, you'll see that the user object is still there.
The "refetch user on window focus" pattern is a solution to this problem.
Luckily, we can use the window.addEventListener
method to listen for the focus
event. This way, we get notified whenever the user activates the tab or window.
Let's add a hook to our page to handle window events.
const windowHooks = (setIsWindowFocused: Dispatch<SetStateAction<"pristine" | "focused" | "blurred">>) => {
useEffect(() => {
const onFocus = () => {
setIsWindowFocused("focused");
};
const onBlur = () => {
setIsWindowFocused("blurred");
};
window.addEventListener('focus', onFocus);
window.addEventListener('blur', onBlur);
return () => {
window.removeEventListener('focus', onFocus);
window.removeEventListener('blur', onBlur);
};
}, []);
}
You'll notice that we're introducing three possible states for the "isWindowFocused" action: pristine, focused and blurred. Why three states? Imagine if we had only two states, focused and blurred. In this case, we'd always have to fire a "focus" event, even if the window was already focused. By introducing the third state (pristine), we can avoid this.
Another important observation you can make is that we're removing the event listeners when the component unmounts. This is very important to avoid memory leaks.
Ok, we've introduced a global state for the window focus. Let's leverage this state to re-fetch the user on window focus by adding another hook:
useEffect(() => {
if (disableFetchUserClientSide) {
return;
}
if (disableFetchUserOnWindowFocus) {
return;
}
if (isWindowFocused !== "focused") {
return
}
const abort = new AbortController();
(async () => {
try {
const nextUser = await ctx.client.fetchUser(abort.signal);
if (JSON.stringify(nextUser) === JSON.stringify(user)) {
return;
}
setUser(nextUser);
} catch (e) {
}
})();
return () => {
abort.abort();
};
}, [isWindowFocused, disableFetchUserClientSide, disableFetchUserOnWindowFocus]);
By adding the isWindowFocused
state to the dependency list, this effect will trigger whenever the window focus changes. We dismiss the events "pristine" and "blurred" and only trigger a user fetch if the window is focused.
Additionally, we make sure that we're only triggering a setState for the user if they actually changed. Otherwise, we might trigger unnecessary re-renders or re-fetches.
Excellent! Our application is now able to handle authentication in various scenarios. That's a great foundation to move on to the actual data-fetching hooks.
The first data-fetching hook we'll look at is the client-side query.
You can open the demo page (http://localhost:3000/patterns/client-side-query) in your browser to get a feel for it.
const data = useQuery.CountryWeather({
input: {
code: "DE",
},
});
So, what's behind useQuery.CountryWeather
? Let's have a look!
function useQueryContextWrapper<Input, Data, Role>(wunderGraphContext: Context<WunderGraphContextProperties<Role>>, query: QueryProps, args?: InternalQueryArgsWithInput<Input>): {
result: QueryResult<Data>;
} {
const {client} = useContext(wunderGraphContext);
const cacheKey = client.cacheKey(query, args);
const [statefulArgs, setStatefulArgs] = useState<InternalQueryArgsWithInput<Input> | undefined>(args);
const [queryResult, setQueryResult] = useState<QueryResult<Data> | undefined>({status: "none"});
useEffect(() => {
if (lastCacheKey === "") {
setLastCacheKey(cacheKey);
return;
}
if (lastCacheKey === cacheKey) {
return;
}
setLastCacheKey(cacheKey);
setStatefulArgs(args);
setInvalidate(invalidate + 1);
}, [cacheKey]);
useEffect(() => {
const abort = new AbortController();
setQueryResult({status: "loading"});
(async () => {
const result = await client.query(query, {
...statefulArgs,
abortSignal: abort.signal,
});
setQueryResult(result as QueryResult<Data>);
})();
return () => {
abort.abort();
setQueryResult({status: "cancelled"});
}
}, [invalidate]);
return {
result: queryResult as QueryResult<Data>,
}
}
Let's explain what's happening here. First, we take the client that's being injected through the React.Context.
We then calculate a cache key for the query and the arguments. This cacheKey helps us to determine whether we need to re-fetch the data.
The initial state of the operation is set to {status: "none"}
. When the first fetch is triggered, the status is set to "loading"
. When the fetch is finished, the status is set to "success"
or "error"
.
If the component wrapping this hook is being unmounted, the status is set to "cancelled"
. Other than that, nothing fancy is happening here. The fetch is only happening when useEffect is triggered.
This means that we're not able to execute the fetch on the server. React.Hooks don't execute on the server. If you look at the demo, you'll notice that there's the flickering again. This is because we're not server-rendering the component. Let's improve this!
In order to execute queries not just on the client but also on the server, we have to apply some changes to our hooks.
Let's first update the useQuery
hook.
function useQueryContextWrapper<Input, Data, Role>(wunderGraphContext: Context<WunderGraphContextProperties<Role>>, query: QueryProps, args?: InternalQueryArgsWithInput<Input>): {
result: QueryResult<Data>;
} {
const {ssrCache, client, isWindowFocused, refetchMountedOperations, user} = useContext(wunderGraphContext);
const isServer = typeof window === 'undefined';
const ssrEnabled = args?.disableSSR !== true && args?.lazy !== true;
const cacheKey = client.cacheKey(query, args);
if (isServer) {
if (ssrEnabled) {
if (ssrCache[cacheKey]) {
return {
result: ssrCache[cacheKey] as QueryResult<Data>,
}
}
const promise = client.query(query, args);
ssrCache[cacheKey] = promise;
throw promise;
} else {
ssrCache[cacheKey] = {
status: "none",
};
return {
result: ssrCache[cacheKey] as QueryResult<Data>,
}
}
}
const [invalidate, setInvalidate] = useState<number>(0);
const [statefulArgs, setStatefulArgs] = useState<InternalQueryArgsWithInput<Input> | undefined>(args);
const [lastCacheKey, setLastCacheKey] = useState<string>("");
const [queryResult, setQueryResult] = useState<QueryResult<Data> | undefined>(ssrCache[cacheKey] as QueryResult<Data> || {status: "none"});
useEffect(() => {
if (lastCacheKey === "") {
setLastCacheKey(cacheKey);
return;
}
if (lastCacheKey === cacheKey) {
return;
}
setLastCacheKey(cacheKey);
setStatefulArgs(args);
if (args?.debounceMillis !== undefined) {
setDebounce(prev => prev + 1);
return;
}
setInvalidate(invalidate + 1);
}, [cacheKey]);
useEffect(() => {
setQueryResult({status: "loading"});
(async () => {
const result = await client.query(query, {
...statefulArgs,
abortSignal: abort.signal,
});
setQueryResult(result as QueryResult<Data>);
})();
return () => {
abort.abort();
setQueryResult({status: "cancelled"});
}
}, [invalidate]);
return {
result: queryResult as QueryResult<Data>,
}
}
We've now updated the useQuery hook to check whether we're on the server or not. If we're on the server, we'll check if data was already resolved for the generated cache key. If the data was resolved, we'll return it. Otherwise, we'll use the client to execute the query using a Promise. But there's a problem. We're not allowed to execute asynchronous code while rendering on the server. So, in theory, we're not able to "wait" for the promise to resolve.
Instead, we have to use a trick. We need to "suspend" the rendering. We can do so by "throwing" the promise that we've just created. Imagine that we're rendering the enclosing component on the server. What we could do is wrap the rendering process of each component in a try/catch block. If one such component throws a promise, we can catch it, wait until the promise resolves, and then re-render the component.
Once the promise is resolved, we're able to populate the cache key with the result. This way, we can immediately return the data when we "try" to render the component for the second time. Using this method, we can move through the component tree and execute all queries that are enabled for server-side-rendering. You might be wondering how to implement this try/catch method. Luckily, we don't have to start from scratch. There's a library called [react-ssr-prepass (https://github.com/FormidableLabs/react-ssr-prepass) that we can use to do this.
Let's apply this to our getInitialProps
function:
WithWunderGraph.getInitialProps = async (ctx: NextPageContext) => {
const pageProps = (Page as NextPage).getInitialProps ? await (Page as NextPage).getInitialProps!(ctx as any) : {};
const ssrCache: { [key: string]: any } = {};
if (typeof window !== 'undefined') {
// we're on the client
// no need to do all the SSR stuff
return {...pageProps, ssrCache};
}
const cookieHeader = ctx.req?.headers.cookie;
if (typeof cookieHeader === "string") {
defaultContextProperties.client.setExtraHeaders({
Cookie: cookieHeader,
});
}
let ssrUser: User<Role> | null = null;
if (options?.disableFetchUserServerSide !== true) {
try {
ssrUser = await defaultContextProperties.client.fetchUser();
} catch (e) {
}
}
const AppTree = ctx.AppTree;
const App = createElement(wunderGraphContext.Provider, {
value: {
...defaultContextProperties,
user: ssrUser,
},
}, createElement(AppTree, {
pageProps: {
...pageProps,
},
ssrCache,
user: ssrUser
}));
await ssrPrepass(App);
const keys = Object.keys(ssrCache).filter(key => typeof ssrCache[key].then === 'function').map(key => ({
key,
value: ssrCache[key]
})) as { key: string, value: Promise<any> }[];
if (keys.length !== 0) {
const promises = keys.map(key => key.value);
const results = await Promise.all(promises);
for (let i = 0; i < keys.length; i++) {
const key = keys[i].key;
ssrCache[key] = results[i];
}
}
return {...pageProps, ssrCache, user: ssrUser};
};
The ctx
object doesn't just contain the req
object but also the AppTree
objects. Using the AppTree
object, we can build the whole component tree and inject our Context Provider, the ssrCache
object, and the user
object.
We can then use the ssrPrepass
function to traverse the component tree and execute all queries that are enabled for server-side-rendering. After doing so, we extract the results from all Promises and populate the ssrCache
object.
Finally, we return the pageProps
object and the ssrCache
object as well as the user
object.
Fantastic! We're now able to apply server-side-rendering to our useQuery hook! It's worth mentioning that we've completely decoupled server-side rendering from having to implement getServerSideProps
in our Page
component. This has a few effects that are important to discuss.
First, we've solved the problem that we have to declare our data dependencies in getServerSideProps
. We're free to put our useQuery hooks anywhere in the component tree, they will always be executed. On the other hand, this approach has the disadvantage that this page will not be statically optimized. Instead, the page will always be server-rendered, meaning that there needs to be a server running to serve the page.
Another approach would be to build a statically rendered page, which can be served entirely from a CDN. That said, we're assuming in this guide that your goal is to serve dynamic content that changed depending on the user. In this scenario, statically rendering the page won't be an option as we don't have any user context when fetching the data.
It's great what we've accomplished so far. But what should happen if the user leaves the window for a while and comes back? Could the data that we've fetched in the past be outdated? If so, how can we deal with this situation? Onto the next pattern!
Luckily, we've already implemented a global context object to propagate the three different window focus states, pristine, blurred, and focused.
Let's leverage the "focused" state to trigger a re-fetch of the query. Remember that we were using the "invalidate" counter to trigger a re-fetch of the query. We can add a new effect to increase this counter whenever the window is focused.
useEffect(() => {
if (!refetchOnWindowFocus) {
return;
}
if (isWindowFocused !== "focused") {
return;
}
setInvalidate(prev => prev + 1);
}, [refetchOnWindowFocus, isWindowFocused]);
That's it! We dismiss all events if refetchOnWindowFocus is set to false or the window is not focused. Otherwise, we increase the invalidate counter and trigger a re-fetch of the query.
If you're following along with the demo, have a look at the refetch-query-on-window-focus page.
The hook, including configuration, looks like this:
const data = useQuery.CountryWeather({
input: {
code: "DE",
},
disableSSR: true,
refetchOnWindowFocus: true,
});
That was a quick one! Let's move on to the next pattern, lazy loading.
As discussed in the problem statement, some of our operations should be executed only after a specific event. Until then, the execution should be deferred.
Let's have a look at the lazy-query page.
const [args,setArgs] = useState<QueryArgsWithInput<CountryWeatherInput>>({
input: {
code: "DE",
},
lazy: true,
});
Setting lazy to true configures the hook to be "lazy". Now, let's look at the implementation:
useEffect(() => {
if (lazy && invalidate === 0) {
setQueryResult({
status: "lazy",
});
return;
}
const abort = new AbortController();
setQueryResult({status: "loading"});
(async () => {
const result = await client.query(query, {
...statefulArgs,
abortSignal: abort.signal,
});
setQueryResult(result as QueryResult<Data>);
})();
return () => {
abort.abort();
setQueryResult({status: "cancelled"});
}
}, [invalidate]);
const refetch = useCallback((args?: InternalQueryArgsWithInput<Input>) => {
if (args !== undefined) {
setStatefulArgs(args);
}
setInvalidate(prev => prev + 1);
}, []);
When this hook is executed for the first time, lazy will be set to true and invalidate will be set to 0. This means that the effect hook will return early and set the query result to "lazy".
A fetch is not executed in this scenario.
If we want to execute the query, we have to increase invalidate by 1. We can do so by calling refetch
on the useQuery hook. That's it! Lazy loading is now implemented.
Let's move on to the next problem: Debouncing user inputs to not fetch the query too often.
Let's say the user want to get the weather for a specific city. My home-town is "Frankfurt am Main", right in the middle of Germany.
That search term is 17 characters long. How often should we fetch the query while the user is typing? 17 times? Once? Maybe twice?
The answer will be somewhere in the middle, but it's definitely not 17 times. So, how can we implement this behavior? Let's have a look at the useQuery hook implementation.
useEffect(() => {
if (debounce === 0) {
return;
}
const cancel = setTimeout(() => {
setInvalidate(prev => prev + 1);
}, args?.debounceMillis || 0);
return () => clearTimeout(cancel);
}, [debounce]);
useEffect(() => {
if (lastCacheKey === "") {
setLastCacheKey(cacheKey);
return;
}
if (lastCacheKey === cacheKey) {
return;
}
setLastCacheKey(cacheKey);
setStatefulArgs(args);
if (args?.debounceMillis !== undefined) {
setDebounce(prev => prev + 1);
return;
}
setInvalidate(invalidate + 1);
}, [cacheKey]);
Let's first have a look at the second useEffect, the one that has the cacheKey as a dependency. You can see that before increasing the invalidate counter, we check if the arguments of the operation contain a debounceMillis property.
If so, we don't immediately increase the invalidate counter. Instead, we increase the debounce counter.
Increasing the debounce counter will trigger the first useEffect, as the debounce counter is a dependency. If the debounce counter is 0, which is the initial value, we immediately return, as there is nothing to do. Otherwise, we start a timer using setTimeout.
Once the timeout is triggered, we increase the invalidate counter. What's special about the effect using setTimeout is that we're leveraging the return function of the effect hook to clear the timeout.
What this means is that if the user types faster than the debounce time, the timer is always cleared and the invalidate counter is not increased. Only when the full debounce time has passed, the invalidate counter is increased.
I see it often that developers use setTimeout but forget to handle the returning object. Not handling the return value of setTimeout might lead to memory leaks, as it's also possible that the enclosing React component unmounts before the timeout is triggered. If you're interested to play around, head over to the demo and try typing different search terms using various debounce times.
Great! We've got a nice solution to debounce user inputs.
Let's now look operations that require the user to be authenticated. We'll start with a server-side protected Query.
Let's say we're rendering a dashboard that requires the user to be authenticated. The dashboard will also show user-specific data. How can we implement this?
Again, we have to modify the useQuery hook.
const {ssrCache, client, isWindowFocused, refetchMountedOperations, user} = useContext(wunderGraphContext);
const isServer = typeof window === 'undefined';
const ssrEnabled = args?.disableSSR !== true && args?.lazy !== true;
const cacheKey = client.cacheKey(query, args);
if (isServer) {
if (query.requiresAuthentication && user === null) {
ssrCache[cacheKey] = {
status: "requires_authentication"
};
return {
result: ssrCache[cacheKey] as QueryResult<Data>,
refetch: () => {
},
};
}
if (ssrEnabled) {
if (ssrCache[cacheKey]) {
return {
result: ssrCache[cacheKey] as QueryResult<Data>,
refetch: () => Promise.resolve(ssrCache[cacheKey] as QueryResult<Data>),
}
}
const promise = client.query(query, args);
ssrCache[cacheKey] = promise;
throw promise;
} else {
ssrCache[cacheKey] = {
status: "none",
};
return {
result: ssrCache[cacheKey] as QueryResult<Data>,
refetch: () => ({}),
}
}
}
As we've discussed in pattern 2, Server-Side User, we've already implemented some logic to fetch the user object in getInitialProps
and inject it into the context.
We also injected the user cookie into the client which is also injected into the context. Together, we're ready to implement the server-side protected query.
If we're on the server, we check if the query requires authentication. This is static information that is defined in the query metadata. If the user object is null, meaning that the user is not authenticated, we return a result with the status "requires_authentication".
Otherwise, we move forward and throw a promise or return the result from the cache. If you go to server-side protected query on the demo, you can play with this implementation and see how it behaves when you log in and out.
That's it, no magic. That wasn't too complicated, was it? Well, the server disallows hooks, which makes the logic a lot easier. Let's now look at what's required to implement the same logic on the client.
To implement the same logic for the client, we need to modify the useQuery hook once again.
useEffect(() => {
if (query.requiresAuthentication && user === null) {
setQueryResult({
status: "requires_authentication",
});
return;
}
if (lazy && invalidate === 0) {
setQueryResult({
status: "lazy",
});
return;
}
const abort = new AbortController();
if (queryResult?.status === "ok") {
setQueryResult({...queryResult, refetching: true});
} else {
setQueryResult({status: "loading"});
}
(async () => {
const result = await client.query(query, {
...statefulArgs,
abortSignal: abort.signal,
});
setQueryResult(result as QueryResult<Data>);
})();
return () => {
abort.abort();
setQueryResult({status: "cancelled"});
}
}, [invalidate, user]);
As you can see, we've now added the user object to the dependencies of the effect. If the query requires authentication, but the user object is null, we set the query result to "requires_authentication" and return early, no fetch is happening. If we pass this check, the query is fired as usual. Making the user object a dependency of the fetch effect also has two nice side-effects.
Let's say, a query requires the user to be authenticated, but they are currently not. The initial query result is "requires_authentication". If the user now logs in, the user object is updated through the context object.
As the user object is a dependency of the fetch effect, all queries are now fired again, and the query result is updated.
On the other hand, if a query requires the user to be authenticated, and the user just logged out, we'll automatically invalidate all queries and set the results to "requires_authentication". Excellent! We've now implemented the client-side protected query pattern. But that's not yet the ideal outcome.
If you're using server-side protected queries, client-side navigation is not handled properly. On the other hand, if we're only using client-side protected queries, we will always have the nasty flickering again. To solve these issues, we have to put both of these patterns together, which leads us to the universal-protected query pattern.
This pattern doesn't require any additional changes as we've already implemented all the logic. All we have to do is configure our page to activate the universal-protected query pattern.
Here's the code from the universal-protected query page
const UniversalProtectedQuery = () => {
const {user,login,logout} = useWunderGraph();
const data = useQuery.ProtectedWeather({
input: {
city: "Berlin",
},
});
return (
<div>
<h1>Universal Protected Query</h1>
<p>{JSON.stringify(user)}</p>
<p>{JSON.stringify(data)}</p>
<button onClick={() => login(AuthProviders.github)}>Login</button>
<button onClick={() => logout()}>Logout</button>
</div>
)
}
export default withWunderGraph(UniversalProtectedQuery);
Have a play with the demo and see how it behaves when you log in and out. Also try to refresh the page or use client-side navigation. What's cool about this pattern is how simple the actual implementation of the page is. The "ProtectedWeather" query hook abstracts away all the complexity of handling authentication, both client- and server-side.
Right, we've spent a lot of time on queries so far, what about mutations? Let's start with an unprotected mutation, one that doesn't require authentication. You'll see that mutation hooks are a lot easier to implement than the query hooks.
function useMutationContextWrapper<Role, Input = never, Data = never>(wunderGraphContext: Context<WunderGraphContextProperties<Role>>, mutation: MutationProps): {
result: MutationResult<Data>;
mutate: (args?: InternalMutationArgsWithInput<Input>) => Promise<MutationResult<Data>>;
} {
const {client, user} = useContext(wunderGraphContext);
const [result, setResult] = useState<MutationResult<Data>>(mutation.requiresAuthentication && user === null ? {status: "requires_authentication"} : {status: "none"});
const mutate = useCallback(async (args?: InternalMutationArgsWithInput<Input>): Promise<MutationResult<Data>> => {
setResult({status: "loading"});
const result = await client.mutate(mutation, args);
setResult(result as any);
return result as any;
}, []);
return {
result,
mutate
}
}
Mutations are not automatically triggered. This means, we're not using
useEffect to trigger the mutation. Instead, we're leveraging the useCallback hook to create a "mutate" function that can be called. Once called, we set the state of the result to "loading" and then call the mutation.
When the mutation is finished, we set the state of the result to the mutation result.This might be a success or a failure. Finally, we return both the result and the mutate function.
Have a look at the unprotected mutation page if you want to play with this pattern.
This was pretty much straight forward.
Let's add some complexity by adding authentication.
function useMutationContextWrapper<Role, Input = never, Data = never>(wunderGraphContext: Context<WunderGraphContextProperties<Role>>, mutation: MutationProps): {
result: MutationResult<Data>;
mutate: (args?: InternalMutationArgsWithInput<Input>) => Promise<MutationResult<Data>>;
} {
const {client, user} = useContext(wunderGraphContext);
const [result, setResult] = useState<MutationResult<Data>>(mutation.requiresAuthentication && user === null ? {status: "requires_authentication"} : {status: "none"});
const mutate = useCallback(async (args?: InternalMutationArgsWithInput<Input>): Promise<MutationResult<Data>> => {
if (mutation.requiresAuthentication && user === null) {
return {status: "requires_authentication"}
}
setResult({status: "loading"});
const result = await client.mutate(mutation, args);
setResult(result as any);
return result as any;
}, [user]);
useEffect(() => {
if (!mutation.requiresAuthentication) {
return
}
if (user === null) {
if (result.status !== "requires_authentication") {
setResult({status: "requires_authentication"});
}
return;
}
if (result.status !== "none") {
setResult({status: "none"});
}
}, [user]);
return {
result,
mutate
}
}
Similarly to the protected query pattern, we're injecting the user object from the context into the callback. If the mutation requires authentication, we check if the user is null. If the user is null, we set the result to "requires_authentication" and return early.
Additionally, we add an effect to check if the user is null. If the user is null, we set the result to "requires_authentication". We've done this so that mutations turn automatically into the "requires_authentication" or "none" state, depending on whether the user is authenticated or not.
Otherwise, you'd first have to call the mutation to figure out that it's not possible to call the mutation. I think it gives us a better developer experience when it's clear upfront if the mutation is possible or not.
Alright, protected mutations are now implemented. You might be wondering why there's no section on server-side mutations, protected or not.
That's because mutations are always triggered by user interaction. So, there's no need for us to implement anything on the server.
That said, there's one problem left with mutations, side effects! What happens if there's a dependency between a list of tasks and a mutation that changes the tasks? Let's make it happen!
For this to work, we need to change both the mutation callback and the query hook. Let's start with the mutation callback.
const {client, setRefetchMountedOperations, user} = useContext(wunderGraphContext);
const mutate = useCallback(async (args?: InternalMutationArgsWithInput<Input>): Promise<MutationResult<Data>> => {
if (mutation.requiresAuthentication && user === null) {
return {status: "requires_authentication"}
}
setResult({status: "loading"});
const result = await client.mutate(mutation, args);
setResult(result as any);
if (result.status === "ok" && args?.refetchMountedOperationsOnSuccess === true) {
setRefetchMountedOperations(prev => prev + 1);
}
return result as any;
}, [user]);
Our goal is to invalidate all currently mounted queries when a mutation is successful. We can do so by introducing yet another global state object which is stored and propagated through the React context.
We call this state object "refetchMountedOperationsOnSuccess", which is a simple counter. In case our mutation callback was successful, we want to increment the counter. This should be enough to invalidate all currently mounted queries.
The second step is to change the query hook.
const {ssrCache, client, isWindowFocused, refetchMountedOperations, user} = useContext(wunderGraphContext);
useEffect(() => {
if (queryResult?.status === "lazy" || queryResult?.status === "none") {
return;
}
setInvalidate(prev => prev + 1);
}, [refetchMountedOperations]);
You should be familiar with the "invalidate" counter already. We're now adding another effect to handle the increment of the "refetchMountedOperations" counter that was injected from the context. You might be asking why we're returning early if the status is "lazy" or "none"?
In case of "lazy", we know that this query was not yet executed, and it's the intention by the developer to only execute it when manually triggered. So, we're skipping lazy queries and wait until they are triggered manually. In case of "none", the same rule applies.
This could happen, e.g. if a query is only server-side-rendered, but we've navigated to the current page via client-side navigation. In such a case, there's nothing we could "invalidate", as the query was not yet executed. We also don't want to accidentally trigger queries that were not yet executed via a mutation side effect.
Want to experience this in action? Head over to the Refetch Mounted Operations on Mutation Success page. Cool! We're done with queries and mutations. Next, we're going to look at implementing hooks for subscriptions.
To implement subscriptions, we have to create a new dedicated hook:
function useSubscriptionContextWrapper<Input, Data, Role>(wunderGraphContext: Context<WunderGraphContextProperties<Role>>, subscription: SubscriptionProps, args?: InternalSubscriptionArgsWithInput<Input>): {
result: SubscriptionResult<Data>;
} {
const {ssrCache, client} = useContext(wunderGraphContext);
const cacheKey = client.cacheKey(subscription, args);
const [invalidate, setInvalidate] = useState<number>(0);
const [subscriptionResult, setSubscriptionResult] = useState<SubscriptionResult<Data> | undefined>(ssrCache[cacheKey] as SubscriptionResult<Data> || {status: "none"});
useEffect(() => {
if (subscriptionResult?.status === "ok") {
setSubscriptionResult({...subscriptionResult, streamState: "restarting"});
} else {
setSubscriptionResult({status: "loading"});
}
const abort = new AbortController();
client.subscribe(subscription, (response: SubscriptionResult<Data>) => {
setSubscriptionResult(response as any);
}, {
...args,
abortSignal: abort.signal
});
return () => {
abort.abort();
}
}, [invalidate]);
return {
result: subscriptionResult as SubscriptionResult<Data>
}
}
The implementation of this hook is similar to the query hook. It's automatically triggered when the enclosing component mounts, so we're using the "useEffect" hook again. It's important to pass an abort signal to the client to ensure that the subscription is aborted when the component unmounts.
Additionally, we want to cancel and re-start the subscription when the invalidate counter, similar to the query hook, is incremented. We've omitted authentication for brevity at this point, but you can assume that it's very similar to the query hook.
Want to play with the example? Head over to the Client-Side Subscription page.
One thing to note, though, is that subscriptions behave differently from queries. Subscriptions are a stream of data that is continuously updated. This means that we have to think about how long we want to keep the subscription open. Should it stay open forever?
Or could there be the case where we want to stop and resume the subscription? One such case is when the user blurs the window, meaning that they're not actively using the application anymore.
In order to stop the subscription when the user blurs the window, we need to extend the subscription hook:
function useSubscriptionContextWrapper<Input, Data, Role>(wunderGraphContext: Context<WunderGraphContextProperties<Role>>, subscription: SubscriptionProps, args?: InternalSubscriptionArgsWithInput<Input>): {
result: SubscriptionResult<Data>;
} {
const {ssrCache, client, isWindowFocused, refetchMountedOperations, user} = useContext(wunderGraphContext);
const isServer = typeof window === 'undefined';
const ssrEnabled = args?.disableSSR !== true;
const cacheKey = client.cacheKey(subscription, args);
const [stop, setStop] = useState(false);
const [invalidate, setInvalidate] = useState<number>(0);
const [stopOnWindowBlur] = useState(args?.stopOnWindowBlur === true);
const [subscriptionResult, setSubscriptionResult] = useState<SubscriptionResult<Data> | undefined>(ssrCache[cacheKey] as SubscriptionResult<Data> || {status: "none"});
useEffect(() => {
if (stop) {
if (subscriptionResult?.status === "ok") {
setSubscriptionResult({...subscriptionResult, streamState: "stopped"});
} else {
setSubscriptionResult({status: "none"});
}
return;
}
if (subscriptionResult?.status === "ok") {
setSubscriptionResult({...subscriptionResult, streamState: "restarting"});
} else {
setSubscriptionResult({status: "loading"});
}
const abort = new AbortController();
client.subscribe(subscription, (response: SubscriptionResult<Data>) => {
setSubscriptionResult(response as any);
}, {
...args,
abortSignal: abort.signal
});
return () => {
abort.abort();
}
}, [stop, refetchMountedOperations, invalidate, user]);
useEffect(() => {
if (!stopOnWindowBlur) {
return
}
if (isWindowFocused === "focused") {
setStop(false);
}
if (isWindowFocused === "blurred") {
setStop(true);
}
}, [stopOnWindowBlur, isWindowFocused]);
return {
result: subscriptionResult as SubscriptionResult<Data>
}
}
For this to work, we introduce a new stateful variable called "stop". The default state will be false, but when the user blurs the window, we'll set the state to true. If they re-enter the window (focus), we'll set the state back to false. If the developer set "stopOnWindowBlur" to false, we'll ignore this, which can be configured in the "args" object of the subscriptions.
Additionally, we have to add the stop variable to the subscription dependencies. That's it! It's quite handy that we've handled the window events globally, this makes all other hooks a lot easier to implement.
The best way to experience the implementation is to open the [Client-Side Subscription (http://localhost:3000/patterns/client-side-subscription) page and carefully watch the network tab in the Chrome DevTools console (or similar if you're using another browser).
Coming back to one of the problems we've described initially, we still have to give an answer to the question of how we can implement server-side rendering for subscriptions, making the subscriptions hook "universal".
You might be thinking that server-side rendering is not possible for subscriptions. I mean, how should you server-render a stream of data?
If you're a regular reader of this blog, you might be aware of our Subscription Implementation. [As we've described in another blog (/blog/deprecate_graphql_subscriptions_over_websockets), we've implemented GraphQL subscriptions in a way that is compatible with the EventSource (SSE) as well as the Fetch API.
We've also added one special flag to the implementation. The client can set the query parameter "wg_subscribe_once" to true. What this means is that a subscription, with this flag set, is essentially a query.
Here's the implementation of the client to fetch a query:
const params = this.queryString({
wg_variables: args?.input,
wg_api_hash: this.applicationHash,
wg_subscribe_once: args?.subscribeOnce,
});
const headers: Headers = {
...this.extraHeaders,
Accept: "application/json",
"WG-SDK-Version": this.sdkVersion,
};
const defaultOrCustomFetch = this.customFetch || globalThis.fetch;
const url = this.baseURL + "/" + this.applicationPath + "/operations/" + query.operationName + params;
const response = await defaultOrCustomFetch(url,
{
headers,
method: 'GET',
credentials: "include",
mode: "cors",
}
);
We take the variables, a hash of the configuration, and the subscribeOnce flag and encode them into the query string.
If subscribe once is set, it's clear to the server that we only want the first result of the subscription.
To give you the full picture, let's also look at the implementation for client-side subscriptions:
private subscribeWithSSE = <S extends SubscriptionProps, Input, Data>(subscription: S, cb: (response: SubscriptionResult<Data>) => void, args?: InternalSubscriptionArgs) => {
(async () => {
try {
const params = this.queryString({
wg_variables: args?.input,
wg_live: subscription.isLiveQuery ? true : undefined,
wg_sse: true,
wg_sdk_version: this.sdkVersion,
});
const url = this.baseURL + "/" + this.applicationPath + "/operations/" + subscription.operationName + params;
const eventSource = new EventSource(url, {
withCredentials: true,
});
eventSource.addEventListener('message', ev => {
const responseJSON = JSON.parse(ev.data);
// omitted for brevity
if (responseJSON.data) {
cb({
status: "ok",
streamState: "streaming",
data: responseJSON.data,
});
}
});
if (args?.abortSignal) {
args.abortSignal.addEventListener("abort", () => eventSource.close());
}
} catch (e: any) {
// omitted for brevity
}
})();
};
The implementation of the subscription client looks similar to the query client, except that we use the EventSource API with a callback. If EventSource is not available, we fall back to the Fetch API, but I'll keep the implementation out of the blog post as it doesn't add much extra value.
The only important thing you should take away from this is that we add a listener to the abort signal. If the enclosing component unmounts or invalidates, it will trigger the abort event, which will close the EventSource.
Keep in mind, if we're doing asynchronous work of any kind, we always need to make sure that we handle cancellation properly, otherwise we might end up with a memory leak. OK, you're now aware of the implementation of the subscription client. Let's wrap the client with easy-to-use subscription hooks that can be used both on the client and on the server.
const {ssrCache, client, isWindowFocused, refetchMountedOperations, user} = useContext(wunderGraphContext);
const isServer = typeof window === 'undefined';
const ssrEnabled = args?.disableSSR !== true;
const cacheKey = client.cacheKey(subscription, args);
if (isServer) {
if (ssrEnabled) {
if (ssrCache[cacheKey]) {
return {
result: ssrCache[cacheKey] as SubscriptionResult<Data>
}
}
const promise = client.query(subscription, {...args, subscribeOnce: true});
ssrCache[cacheKey] = promise;
throw promise;
} else {
ssrCache[cacheKey] = {
status: "none",
}
return {
result: ssrCache[cacheKey] as SubscriptionResult<Data>
}
}
}
Similarly to the useQuery hook, we add a code branch for the server-side rendering. If we're on the server and don't yet have any data, we make a "query" request with the subscribeOnce flag set to true.
As described above, a subscription with the flag subscribeOnce set to true, will only return the first result, so it behaves like a query. That's why we use client.query()
instead of client.subscribe()
. Some comments on the blog post about our subscription implementation indicated that it's not that important to make subscriptions stateless.
I hope that at this point its clear why we've gone this route. Fetch support just landed in NodeJS, and even before that we've had node-fetch as a polyfill. It would definitely be possible to initiate subscriptions on the server using WebSockets, but ultimately I think it's much easier to just use the Fetch API and not have to worry about WebSocket connections on the server.
The best way to play around with this implementation is to go to the universal subscription page. When you refresh the page, have a look at the "preview" of the first request. You'll see that the page will come server-rendered compared to the client-side subscription. Once the client is re-hydrated, it'll start a subscription by itself to keep the user interface updated.
That was a lot of work, but we're not yet done. Subscriptions should also be protected using authentication, let's add some logic to the subscription hook.
You'll notice that it's very similar to a regular query hook.
const {ssrCache, client, isWindowFocused, refetchMountedOperations, user} = useContext(wunderGraphContext);
const [subscriptionResult, setSubscriptionResult] = useState<SubscriptionResult<Data> | undefined>(ssrCache[cacheKey] as SubscriptionResult<Data> || {status: "none"});
useEffect(() => {
if (subscription.requiresAuthentication && user === null) {
setSubscriptionResult({
status: "requires_authentication",
});
return;
}
if (stop) {
if (subscriptionResult?.status === "ok") {
setSubscriptionResult({...subscriptionResult, streamState: "stopped"});
} else {
setSubscriptionResult({status: "none"});
}
return;
}
if (subscriptionResult?.status === "ok") {
setSubscriptionResult({...subscriptionResult, streamState: "restarting"});
} else {
setSubscriptionResult({status: "loading"});
}
const abort = new AbortController();
client.subscribe(subscription, (response: SubscriptionResult<Data>) => {
setSubscriptionResult(response as any);
}, {
...args,
abortSignal: abort.signal
});
return () => {
abort.abort();
}
}, [stop, refetchMountedOperations, invalidate, user]);
First, we have to add the user as a dependency to the effect. This will make the effect trigger whenever the user changes. Then, we have to check the meta-data of the subscription and see if it requires authentication.
If it does, we check if the user is logged in. If the user is logged in, we continue with the subscription. If the user is not logged in, we set the subscription result to "requires_authentication".
That's it! Authentication-aware universal Subscriptions done! Let's have a look at our end-result:
const ProtectedSubscription = () => {
const {login,logout,user} = useWunderGraph();
const data = useSubscription.ProtectedPriceUpdates();
return (
<div>
<p>{JSON.stringify(user)}</p>
<p style={{height: "8vh"}}>{JSON.stringify(data)}</p>
<button onClick={() => login(AuthProviders.github)}>Login</button>
<button onClick={() => logout()}>Logout</button>
</div>
)
}
export default withWunderGraph(ProtectedSubscription);
Isn't it great how we're able to hide so much complexity behind a simple API? All these things, like authentication, window focus and blur, server-side rendering, client-side rendering, passing data from server to client, proper re-hydration of the client, it's all handled for us.
On top of that, the client is mostly using generics and wrapped by a small layer of generated code, making the whole client fully type-safe. Type-safety was one of our requirements if you remember.
Some API clients "can" be type-safe. Others allow you to add some extra code to make them type-safe. With our approach, a generic client plus auto-generated types, the client is always type-safe.
It's a manifest for us that so far, nobody has asked us to add a "pure" JavaScript client. Our users seem to accept and appreciate that everything is type-safe out of the box. We believe that type-safety helps developers to make less errors and to better understand their code.
Want to play with protected, universal subscriptions yourself? Check out the protected-subscription page of the demo. Don't forget to check Chrome DevTools and the network tab to get the best insights. Finally, we're done with subscriptions. Two more patterns to go, and we're done completely.
The last pattern we're going to cover is Live Queries. Live Queries are similar to Subscriptions in how they behave on the client side. Where they differ is on the server side. Let's first discuss how live queries work on the server and why they are useful. If a client "subscribes" to a live query, the server will start to poll the origin server for changes.
It will do so in a configurable interval, e.g. every one second. When the server receives a change, it will hash the data and compare it to the hash of the last change. If the hashes are different, the server will send the new data to the client. If the hashes are the same, we know that nothing changed, so we don't send anything to the client.
Why and when are live queries useful?
First, a lot of existing infrastructure doesn't support subscriptions. Adding live-queries at the gateway level means that you're able to add "real-time" capabilities to your existing infrastructure. You could have a legacy PHP backend which you don't want to touch anymore. Add live queries on top of it and your frontend will be able to receive real-time updates.
You might be asking why not just do the polling from the client side? Client-side polling could result in a lot of requests to the server. Imagine if 10.000 clients make one request per second. That's 10.000 requests per second. Do you think your legacy PHP backend can handle that kind of load?
10.000 clients connect to the api gateway and subscribe to a live query. The gateway can then bundle all the requests together, as they are essentially asking for the same data, and make one single request to the origin.
Using live-queries, we're able to reduce the number of requests to the origin server, depending on how many "streams" are being used.
So, how can we implement live-queries on the client?
Have a look at the "generated" wrapper around the generic client for one of our operations:
CountryWeather: (args: SubscriptionArgsWithInput<CountryWeatherInput>) =>
hooks.useSubscriptionWithInput<CountryWeatherInput, CountryWeatherResponseData, Role>(WunderGraphContext, {
operationName: "CountryWeather",
isLiveQuery: true,
requiresAuthentication: false,
})(args)
Looking at this example, you can notice a few things. First, we're using the useSubscriptionWithInput
hook.
This indicates that we actually don't have to distinguish between a subscription and a live query, at least not from a client-side perspective.
The only difference is that we're setting the isLiveQuery
flag to true
. For subscriptions, we're using the same hook, but set the isLiveQuery
flag to false
.
As we've already implemented the subscription hook above, there's no additional code required to make live-queries work. Check out the live-query page of the demo. One thing you might notice is that this example has the nasty flickering again, that's because we're not server-side rendering it.
The final and last pattern we're going to cover is Universal Live Queries. Universal Live Queries are similar to Subscriptions, just simpler from the server-side perspective.
For the server, to initiate a subscription, it has to open a WebSocket connection to the origin server, make the handshake, subscribe, etc... If we need to subscribe once with a live query, we're simply "polling" once, which means, we're just making a single request.
So, live queries are actually a bit faster to initiate compared to subscriptions, at least on the initial request. How can we use them? Let's look at an example from the demo:
const UniversalLiveQuery = () => {
const data = useLiveQuery.CountryWeather({
input: {
code: "DE",
},
});
return (
<p>{JSON.stringify(data)}</p>
)
}
export default withWunderGraph(UniversalLiveQuery);
That's it, that's your stream of weather data for the capital of Germany, Berlin, which is being updated every second.
You might be wondering how we've got the data in the first place. Let's have a look at the definition of the CountryWeather
operation:
query ($capital: String! @internal $code: ID!) {
countries_country(code: $code){
code
name
capital @export(as: "capital")
weather: _join @transform(get: "weather_getCityByName.weather") {
weather_getCityByName(name: $capital){
weather {
temperature {
actual
}
summary {
title
description
}
}
}
}
}
}
We're actually joining data from two disparate services. First, we're using a countries API to get the capital of a country. We export the field capital
into the internal $capital
variable.
Then, we're using the _join
field to combine the country data with a weather API. Finally, we apply the @transform
directive to flatten the response a bit.
It's a regular, valid, GraphQL query. Combined with the live-query pattern, we're now able to live-stream the weather for any capital of any country. Cool, isn't it?
Similar to all the other patterns, this one can also be tried and tested on the demo. Head over to the universal-live-query page and have a play!
That's it! We're done!
I hope you've learned how you're able to build universal, authentication-aware data-fetching hooks. Before we're coming to an end of this post, I'd like to look at alternative approaches and tools to implement data fetching hooks.
One major drawback of using server-side rendering is that the client has to wait until the server has finished rendering the page. Depending on the complexity of the page, this might take a while, especially if you have to make many chained requests to fetch all the data required for the page.
One solution to this problem is to statically generate the page on the server. NextJS allows you to implement an asynchronous getStaticProps
function on top of each page.
This function is called at built time, and it's responsible for fetching all the data required for the page. If, at the same time, you don't attach a getInitialProps
or getServerSideProps
function to the page, NextJS considers this page to be static, meaning that no NodeJS process will be required to render the page. In this scenario, the page will be pre-rendered at compile time, allowing it to be cached by a CDN.
This way of rendering makes the application extremely fast and easy to host, but there's also drawbacks. For one, a static page is not user-specific. That's because at built time, there's no context of the user. This is not a problem for public pages though. It's just that you can't use user-specific pages like dashboards this way.
A tradeoff that can be made is to statically render the page and add user-specific content on the client side. However, this will always introduce flickering on the client, as the page will update very shortly after the initial render.
So, if you're building an application that requires the user to be authenticated, you might want to use server-side rendering instead.
The second drawback of static site generation is that content can become outdated if the underlying data changes. In that case, you might want to re-build the page. However, rebuilding the whole page might take a long time and might be unnecessary if only a few pages need to be rebuilt. Luckily, there's a solution to this problem: Incremental Static Regeneration.
Incremental Static Regeneration allows you to invalidate individual pages and re-render them on demand. This gives you the performance advantage of a static site, but removes the problem of outdated content.
That said, this still doesn't solve the problem with authentication, but I don't think this is what static site generation is all about. On our end, we're currently looking at patterns where the result of a Mutation could automatically trigger a page-rebuild using ISR. Ideally, this could be something that works in a declarative way, without having to implement custom logic.
One issue that you might run into with server-side rendering (but also client-side) is that while traversing the component tree, the server might have to create a huge waterfall of queries that depend on each other.
If child components depend on data from their parents, you might easily run into the N+1 problem. N+1 in this case means that you fetch an array of data in a root component, and then for each of the array items, you'll have to fire an additional query in a child component.
Keep in mind that this problem is not specific to using GraphQL. GraphQL actually has a solution to solve it while REST APIs suffer from the same problem. The solution is to use GraphQL fragments with a client that properly supports them.
The creators of GraphQL, Facebook / Meta, have created a solution for this problem, it's called the Relay Client.
The Relay Client is a library that allows you to specify your "Data Requirements" side-by-side with the components via GraphQL fragments. Here's an example of how this could look like:
import type {UserComponent_user$key} from 'UserComponent_user.graphql';
const React = require('React');
const {graphql, useFragment} = require('react-relay');
type Props = {
user: UserComponent_user$key,
};
function UserComponent(props: Props) {
const data = useFragment(
graphql`
fragment UserComponent_user on User {
name
profile_picture(scale: 2) {
uri
}
}
`,
props.user,
);
return (
<>
<h1>{data.name}</h1>
<div>
<img src={data.profile_picture?.uri} />
</div>
</>
);
}
If this was a nested component, the fragment allows us hoist our data requirements up to the root component. This means that the root component will be capable of fetching the data for its children, while keeping the data requirements definition in the child components.
Fragments allow for a loose coupling between parent and child components, while allowing for a more efficient data fetching process. For a lot of developers, this is the actual reason why they are using GraphQL. It's not that they use GraphQL because they want to use the Query Language, it's because they want to leverage the power of the Relay Client.
For us, the Relay Client is a great source of inspiration.
I actually think that using Relay is too hard. In our next iteration, we're looking at adopting the "Fragment hoisting" approach, but our goal is to make it easier to use than the Relay Client.
Another development that's happening in the React world is the creation of React Suspense. As you've seen above, we're already using Suspense on the server. By "throwing" a promise, we're able to suspend the rendering of a component until the promise is resolved. That's an excellent way to handle asynchronous data fetching on the server.
However, you're also able to apply this technique on the client. Using Suspense on the client allows us to "render-while-fetching" in a very efficient way. Additionally, clients that support Suspense allow for a more elegant API for data fetching hooks. Instead of having to handle "loading" or "error" states within the component, suspense will "push" these states to the next "error boundary" and handles them there.
This approach makes the code within the component a lot more readable as it only handles the "happy path". As we're already supporting Suspense on the server, you can be sure that we're adding client support in the future as well. We just want to figure out the most idiomatic way of supporting both a suspense and a non-suspense client.
This way, users get the freedom to choose the programming style they prefer.
We're not the only ones who try to improve the data fetching experience in NextJS. Therefore, let's have a quick look at other technologies and how they compare to the approach we're proposing.
We've actually taken a lot of inspiration from swr. If you look at the patterns we've implemented, you'll see that swr really helped us to define a great data fetching API.
There's a few things where our approach differs from swr which might be worth mentioning.
SWR is a lot more flexible and easier to adopt because you can use it with any backend. The approach we've taken, especially the way we're handling authentication, requires you to also run a WunderGraph backend that provides the API we're expecting.
E.g. if you're using the WunderGraph client, we're expecting that the backend is a OpenID Connect Relying Party. The swr client on the other hand doesn't make such assumptions.
I personally believe that with a library like swr, you'll eventually end up with a similar outcome as if you were using the WunderGraph client in the first place. It's just that you're now maintaining more code as you had to add authentication logic.
The other big difference is server-side rendering. WunderGraph is carefully designed to remove any unnecessary flickering when loading an application that requires authentication.
The docs from swr explain that this is not a problem and users are ok with loading spinners in dashboards.
I think we can do better than that. I know of SaaS dashboards that take 15 or more seconds to load all components including content. Over this period of time, the user interface is not usable at all, because it keeps "wiggling" all the content into the right place.
Why can't we pre-render the whole dashboard and then re-hydrate the client? If the HTML is rendered in the correct way, links should be clickable even before the JavaScript client is loaded. If your whole "backend" fits into the "/api" directory of your NextJS application, your best choice is probably to use the "swr" library. Combined with NextAuthJS, this can make for a very good combination.
If you're instead building dedicated services to implement APIs, a "backend-for-frontend" approach, like the one we're proposing with WunderGraph, could be a better choice as we're able to move a lot of repetitive logout out of your services and into the middleware.
Speaking of NextAuthJS, why not just add authentication directly into your NextJS application?
The library is designed to solve exactly this problem, adding authentication to your NextJS application with minimal effort. From a technical perspective, NextAuthJS follows similar patterns as WunderGraph. There's just a few differences in terms of the overall architecture.
If you're building an application will never scale beyond a single website, you can probably use NextAuthJS. However, if you're planning to use multiple websites, cli tools, native apps, or even connect a backend, you're better off using a different approach.
Let me explain why.
The way NextAuthJS is implemented is that it's actually becoming the "Issuer" of the authentication flow. That said, it's not an OpenID Connect compliant Issuer, it's a custom implementation. So, while it's easy to get started, you're actually adding a lot of technical debt at the beginning.
Let's say you'd like to add another dashboard, or a cli tool or connect a backend to your APIs. If you were using an OpenID Connect compliant Issuer, there's already a flow implemented for various different scenarios.
Additionally, this OpenID Connect provider is only loosely coupled to your NextJS application.Making your application itself the issuer means that you have to re-deploy and modify your "frontend" application, whenever you want to modify the authentication flow.
You'll also not be able to use standardized authentication flows like code-flow with pkce, or the device flow. Authentication should be handled outside the application itself.
We've recently announced our partnership with Cloud IAM, which makes setting up an OpenID Connect Provider with WunderGraph as the Relying Party a matter of minutes. I hope that we're making it easy enough for you so you don't have to build your own authentication flows.
The data-fetching layer and hooks is actually very much the same as WunderGraph. I think that we're even using the same approach for server-side rendering in NextJS.
The trpc has obviously very little to do with GraphQL, compared to WunderGraph. It's story around authentication is also not as complete as WunderGraph.
That said, I think that Alex has done a great job of building trpc. It's less opinionated than WunderGraph, which makes it a great choice for different scenarios.
From my understanding, trpc works best when both backend and frontend use TypeScript. WunderGraph takes a different path. The common middle ground to define the contract between client and server is JSON-RPC, defined using JSON Schema. Instead of simply importing the server types into the client, you have to go through a code-generation process with WunderGraph.
This means, the setup is a bit more complex, but we're able to not just support TypeScript as a target environment, but any other language or runtime that supports JSON over HTTP.
There are many other GraphQL clients, like Apollo Client, urql and graphql-request. What all of them have in common is that they don't usually use JSON-RPC as the transport.
I've probably written this in multiple blog posts before, but sending read requests over HTTP POST just breaks the internet. If you're not changing GraphQL Operations, like 99% of all applications who use a compile/transpile step, why use a GraphQL client that does this?
Clients, Browsers, Cache-Servers, Proxies and CDNs, they all understand Cache-Control headers and ETags.
The popular NextJS data fetching client "swr" has its name for a reason, because swr stands for "stale while revalidate", which is nothing else but the pattern leveraging ETags for efficient cache invalidation.
GraphQL is a great abstraction to define data dependencies. But when it comes to deploying web scale applications, we should be leveraging the existing infrastructure of the web.
What this means is this: GraphQL is great during development, but in production, we should be leveraging the principles of REST as much as we can.
Building good data-fetching hooks for NextJS and React in general is a challenge. We've also discussed that we're arriving at somewhat different solutions if we're taking authentication into account from the very beginning.
I personally believe that adding authentication right into the API layer on both ends, backend and frontend, makes for a much cleaner approach. Another aspect to think about is where to put the authentication logic. Ideally, you're not implementing it yourself but can rely on a proper implementation.
Combining OpenID Connect as the Issuer with a Relying Party in your backend-for-frontend (BFF) is a great way of keeping things decoupled but still very controllable.
Our BFF is still creating and validating cookies, but it's not the source of truth. We're always delegating to Keycloak.
What's nice about this setup is that you can easily swap Keycloak for another implementation, that's the beauty of relying on interfaces instead of concrete implementations.
Finally, I hope that I'm able to convince you that more (SaaS) dashboards should adopt server-side rendering. NextJS and WunderGraph make it so easy to implement, it's worth a try.
Once again, if you're interested to play around with a demo, here's the repository:
https://github.com/wundergraph/wundergraph-demo
Source: https://hackernoon.com/fetch-the-right-data-with-nextjs-and-react-ssr
1652116800
Un desarrollador frontend debería poder definir qué datos se necesitan para una página determinada, sin tener que preocuparse por cómo los datos llegan realmente a la interfaz.
Eso es lo que dijo un amigo mío recientemente en una discusión. ¿Por qué no hay una forma sencilla de obtener datos universales en NextJS?
Para responder a esta pregunta, echemos un vistazo a los desafíos relacionados con la obtención universal de datos en NextJS. Pero primero, ¿qué es realmente la obtención universal de datos?
Descargo de responsabilidad: Este va a ser un artículo largo y detallado. Va a cubrir mucho terreno y profundizará bastante en los detalles. Si espera un blog de marketing ligero, este artículo no es para usted.
Mi definición de obtención universal de datos es que puede colocar un gancho de obtención de datos en cualquier lugar de su aplicación, y simplemente funcionaría. Este gancho de obtención de datos debería funcionar en todas partes de su aplicación sin ninguna configuración adicional.
Aquí hay un ejemplo, probablemente el más complicado, pero estoy demasiado emocionado como para no compartirlo contigo.
Este es un gancho de "suscripción universal".
const PriceUpdates = () => {
const data = useSubscription.PriceUpdates();
return (
<div>
<h1>Universal Subscription</h1>
<p>{JSON.stringify(data)}</p>
</div>
)
}
Nuestro marco genera el gancho "PriceUpdates" ya que hemos definido un archivo "PriceUpdates.graphql" en nuestro proyecto.
¿Qué tiene de especial este gancho? Puede colocar React Component en cualquier lugar de su aplicación. De forma predeterminada, el servidor renderizará el primer elemento de la suscripción. El HTML generado por el servidor se enviará al cliente, junto con los datos. El cliente rehidratará la aplicación e iniciará una suscripción por sí mismo.
Todo esto se hace sin ninguna configuración adicional. Funciona en todas partes de su aplicación, de ahí el nombre, obtención universal de datos. Defina los datos que necesita, escribiendo una operación GraphQL, y el marco se encargará del resto.
Tenga en cuenta que no estamos tratando de ocultar el hecho de que se están realizando llamadas de red. Lo que estamos haciendo aquí es devolverles a los desarrolladores frontend su productividad. No debería preocuparse por cómo se obtienen los datos, cómo proteger la capa API, qué transporte usar, etc. Debería funcionar.
Si ha estado usando NextJS por un tiempo, es posible que se pregunte qué debería ser exactamente difícil en la obtención de datos.
En NextJS, simplemente puede definir un punto final en el directorio "/api", al que luego se puede llamar usando "swr" o simplemente "buscar".
Es correcto que el "¡Hola, mundo!" El ejemplo de obtener datos de "/api" es realmente simple, pero escalar una aplicación más allá de la primera página puede abrumar rápidamente al desarrollador.
Veamos los principales desafíos de la obtención de datos en NextJS.
De forma predeterminada, el único lugar donde puede usar funciones asíncronas para cargar datos necesarios para la representación del lado del servidor es en la raíz de cada página.
Aquí hay un ejemplo de la documentación de NextJS:
function Page({ data }) {
// Render data...
}
// This gets called on every request
export async function getServerSideProps() {
// Fetch data from external API
const res = await fetch(`https://.../data`)
const data = await res.json()
// Pass data to the page via props
return { props: { data } }
}
export default Page
Imagine un sitio web con cientos de páginas y componentes. Si tiene que definir todas las dependencias de datos en la raíz de cada página, ¿cómo sabe qué datos se necesitan realmente antes de representar el árbol de componentes? Dependiendo de los datos que haya cargado para los componentes raíz, alguna lógica podría decidir cambiar completamente los componentes secundarios.
He hablado con desarrolladores que tienen que mantener grandes aplicaciones de NextJS. Han declarado claramente que la obtención de datos en "getServerSideProps" no se escala bien con una gran cantidad de páginas y componentes.
La mayoría de las aplicaciones tienen algún tipo de mecanismo de autenticación. Puede haber algún contenido que esté disponible públicamente, pero ¿qué sucede si desea personalizar un sitio web?
Habrá una necesidad de renderizar diferentes contenidos para diferentes usuarios.
Cuando presenta contenido específico del usuario solo en el cliente, ¿ha notado este feo efecto de "parpadeo" una vez que ingresan los datos?
Si solo está representando el contenido específico del usuario en el cliente, siempre obtendrá el efecto de que la página se volverá a representar varias veces hasta que esté lista.
Idealmente, nuestros ganchos de obtención de datos serían conscientes de la autenticación desde el primer momento.
Como hemos visto en el ejemplo anterior usando "getServerSideProps", necesitamos tomar acciones adicionales para hacer que nuestra capa de API sea segura. ¿No sería mejor si los ganchos de obtención de datos fueran de tipo seguro por defecto?
Hasta ahora, nunca he visto a nadie que haya aplicado renderizado del lado del servidor en NextJS a las suscripciones. Pero, ¿qué sucede si desea representar el precio de las acciones en el servidor por razones de rendimiento y SEO, pero también desea tener una suscripción del lado del cliente para recibir actualizaciones?
Seguramente, podría usar una solicitud Query/GET en el servidor y luego agregar una suscripción en el cliente, pero esto agrega mucha complejidad. ¡Debería haber una manera más simple!
Otra pregunta que surge es qué debería pasar si el usuario sale y vuelve a entrar en la ventana. ¿Deberían detenerse las suscripciones o continuar transmitiendo datos? Según el caso de uso y el tipo de aplicación, es posible que desee modificar este comportamiento, según la experiencia esperada del usuario y el tipo de datos que está obteniendo. Nuestros ganchos de obtención de datos deberían poder manejar esto.
Es bastante común que las mutaciones tengan efectos secundarios en otros ganchos de obtención de datos. Por ejemplo, podría tener una lista de tareas. Cuando agrega una nueva tarea, también desea actualizar la lista de tareas. Por lo tanto, los ganchos de obtención de datos deben poder manejar este tipo de situaciones.
Otro patrón común es la carga diferida. Es posible que desee cargar datos solo en determinadas condiciones, por ejemplo, cuando el usuario se desplaza hasta la parte inferior de la página o cuando hace clic en un botón. En tales casos, nuestros ganchos de obtención de datos deberían poder diferir la ejecución de la obtención hasta que realmente se necesiten los datos.
Otro requisito importante para los ganchos de obtención de datos es eliminar el rebote de la ejecución de una consulta. Esto es para evitar solicitudes innecesarias al servidor. Imagine una situación en la que un usuario escribe un término de búsqueda en un cuadro de búsqueda. ¿Realmente debería hacer una solicitud al servidor cada vez que el usuario escribe una carta? Veremos cómo podemos usar el antirrebote para evitar esto y hacer que nuestros ganchos de obtención de datos sean más eficaces.
Eso nos lleva a 8 problemas centrales que debemos resolver. Analicemos ahora 21 patrones y mejores prácticas para resolver estos problemas.
Si desea seguir y experimentar estos patrones usted mismo, puede clonar este repositorio y jugar . Para cada patrón, hay una página dedicada en la demostración .
Una vez que haya iniciado la demostración, puede abrir su navegador y encontrar la descripción general de los patrones en http://localhost:3000/patterns
.
Notará que estamos usando GraphQL para definir nuestros ganchos de obtención de datos, pero la implementación realmente no es específica de GraphQL. Puede aplicar los mismos patrones con otros estilos de API como REST, o incluso con una API personalizada.
El primer patrón que veremos es el usuario del lado del cliente, es la base para construir ganchos de obtención de datos con reconocimiento de autenticación.
Aquí está el gancho para buscar al usuario actual:
useEffect(() => {
if (disableFetchUserClientSide) {
return;
}
const abort = new AbortController();
if (user === null) {
(async () => {
try {
const nextUser = await ctx.client.fetchUser(abort.signal);
if (JSON.stringify(nextUser) === JSON.stringify(user)) {
return;
}
setUser(nextUser);
} catch (e) {
}
})();
}
return () => {
abort.abort();
};
}, [disableFetchUserClientSide]);
Dentro de la raíz de nuestra página, usaremos este enlace para obtener el usuario actual (si aún no se obtuvo en el servidor). Es importante pasar siempre el controlador de cancelación al cliente, de lo contrario, podríamos tener pérdidas de memoria. La función de flecha de retorno se llama cuando se desmonta el componente que contiene el gancho.
Notará que estamos usando este patrón en toda nuestra aplicación para manejar las posibles fugas de memoria de manera adecuada.
Veamos ahora la implementación de "client.fetchUser".
public fetchUser = async (abortSignal?: AbortSignal, revalidate?: boolean): Promise<User<Role> | null> => {
try {
const revalidateTrailer = revalidate === undefined ? "" : "?revalidate=true";
const response = await fetch(this.baseURL + "/" + this.applicationPath + "/auth/cookie/user" + revalidateTrailer, {
headers: {
...this.extraHeaders,
"Content-Type": "application/json",
"WG-SDK-Version": this.sdkVersion,
},
method: "GET",
credentials: "include",
mode: "cors",
signal: abortSignal,
});
if (response.status === 200) {
return response.json();
}
} catch {
}
return null;
};
Notará que no estamos enviando ninguna credencial de cliente, token o cualquier otra cosa. Implícitamente enviamos la cookie segura, encriptada y solo http que configuró el servidor, a la que nuestro cliente no tiene acceso.
Para aquellos que no lo saben, las cookies de solo http se adjuntan automáticamente a cada solicitud si se encuentra en el mismo dominio. Si está utilizando HTTP/2, también es posible que el cliente y el servidor apliquen compresión de encabezado, lo que significa que la cookie no tiene que enviarse en cada solicitud, ya que tanto el cliente como el servidor pueden negociar un mapa de valor de clave de encabezado conocido. pares en el nivel de conexión.
El patrón que estamos usando detrás de escena para hacer que la autenticación sea tan simple se llama "Patrón de controlador de token". El patrón del controlador de tokens es la forma más segura de manejar la autenticación en las aplicaciones JavaScript modernas. Si bien es muy seguro, también nos permite ser independientes del proveedor de identidad.
Al aplicar el patrón del controlador de tokens, podemos cambiar fácilmente entre diferentes proveedores de identidad. Esto se debe a que nuestro "backend" actúa como una parte dependiente de OpenID Connect.
¿Qué es una parte dependiente, podrías preguntar? Es una aplicación con un cliente OpenID Connect que externaliza la autenticación a un tercero. Como estamos hablando en el contexto de OpenID Connect, nuestro "backend" es compatible con cualquier servicio que implemente el protocolo OpenID Connect. De esta forma, nuestro backend puede brindar una experiencia de autenticación perfecta, mientras que los desarrolladores pueden elegir entre diferentes proveedores de identidad, como Keycloak, Auth0, Okta, Ping Identity, etc.
¿Cómo se ve el flujo de autenticación desde la perspectiva de los usuarios?
A partir de ahora, cuando el cliente llame al fetchUser
método, enviará automáticamente la cookie al backend. De esta manera, la interfaz siempre tiene acceso a la información del usuario mientras está conectado.
Si el usuario hace clic en cerrar sesión, llamaremos a una función en el backend que invalidará la cookie.
Todo esto puede ser mucho para digerir, así que resumamos las partes esenciales. Primero, debe decirle al backend con qué proveedores de identidad trabajar para que pueda actuar como Reyling Party. Una vez hecho esto, podrá iniciar el flujo de autenticación desde el frontend, obtener al usuario actual del backend y cerrar la sesión.
Si envolvemos esta llamada "fetchUser" en un enlace useEffect
que colocamos en la raíz de cada página, siempre sabremos cuál es el usuario actual.
Sin embargo, hay una trampa. Si abre la demostración y se dirige a la página de usuario del lado del cliente , notará que hay un efecto de parpadeo después de cargar la página, eso se debe a que la fetchUser
llamada se está realizando en el cliente.
Si observa Chrome DevTools y abre la vista previa de la página, notará que la página se representa con el objeto de usuario establecido en null
. Puede hacer clic en el botón de inicio de sesión para iniciar el flujo de inicio de sesión. Una vez completado, actualice la página y verá el efecto de parpadeo.
Ahora que comprende la mecánica detrás del patrón del controlador de fichas, echemos un vistazo a cómo podemos eliminar el parpadeo en la carga de la primera página.
Si desea deshacerse del parpadeo, tenemos que cargar al usuario en el lado del servidor para que pueda aplicar la representación del lado del servidor. Al mismo tiempo, tenemos que llevar de alguna manera el usuario renderizado del lado del servidor al cliente. Si omitimos ese segundo paso, la rehidratación del cliente fallará ya que el html generado por el servidor diferirá del primer procesamiento del lado del cliente.
Entonces, ¿cómo obtenemos acceso al objeto de usuario en el lado del servidor? Recuerde que todo lo que tenemos es una cookie adjunta a un dominio.
Digamos que nuestro backend se ejecuta en api.example.com
, y el frontend se ejecuta en www.example.com
o example.com
.
Si hay algo importante que debe saber sobre las cookies es que puede establecer cookies en los dominios principales si está en un subdominio. Esto significa que, una vez que se completa el flujo de autenticación, el backend NO debe establecer la cookie en el api.example.com
dominio. En su lugar, debería establecer la cookie en el example.com
dominio. Al hacerlo, la cookie se vuelve visible para todos los subdominios de example.com
, incluido www.example.com
, api.example.com
y para example.com
ella misma.
Por cierto, este es un patrón excelente para implementar el inicio de sesión único. Haga que sus usuarios inicien sesión una vez y se autentican en todos los subdominios.
WunderGraph establece automáticamente las cookies en el dominio principal si el backend está en un subdominio, por lo que no tiene que preocuparse por esto.
Ahora, volvamos a poner al usuario en el lado del servidor. Para llevar al usuario del lado del servidor, tenemos que implementar alguna lógica en el getInitialProps
método de nuestras páginas.
WunderGraphPage.getInitialProps = async (ctx: NextPageContext) => {
// ... omitted for brevity
const cookieHeader = ctx.req?.headers.cookie;
if (typeof cookieHeader === "string") {
defaultContextProperties.client.setExtraHeaders({
Cookie: cookieHeader,
});
}
let ssrUser: User<Role> | null = null;
if (options?.disableFetchUserServerSide !== true) {
try {
ssrUser = await defaultContextProperties.client.fetchUser();
} catch (e) {
}
}
// ... omitted for brevity
return {...pageProps, ssrCache, user: ssrUser};
El ctx
objeto de la getInitialProps
función contiene la solicitud del cliente, incluidos los encabezados. Podemos hacer un "truco de magia" para que el "cliente API", que creamos en el lado del servidor, pueda actuar en nombre del usuario.
Como tanto el frontend como el backend comparten el mismo dominio principal, tenemos acceso a la cookie que configuró el backend. Entonces, si tomamos el encabezado de la cookie y lo configuramos como el Cookie
encabezado del cliente API, el cliente API podrá actuar en el contexto del usuario, ¡incluso en el lado del servidor!
Ahora podemos buscar al usuario en el lado del servidor y pasar el objeto de usuario junto con los pageProps a la función de representación de la página. Asegúrese de no perder este último paso, de lo contrario la rehidratación del cliente fallará.
Muy bien, hemos resuelto el problema del parpadeo, al menos cuando presionas actualizar. Pero, ¿qué sucede si comenzamos en una página diferente y usamos la navegación del lado del cliente para llegar a esta página?
Abra la demostración y pruébelo usted mismo. Verá que el objeto de usuario se establecerá en null
si el usuario no se cargó en la otra página.
Para resolver también este problema, tenemos que ir un paso más allá y aplicar el patrón de "usuario universal".
El patrón de usuario universal es la combinación de los dos patrones anteriores.
Si estamos accediendo a la página por primera vez, cargue al usuario en el lado del servidor, si es posible, y renderice la página. En el lado del cliente, rehidratamos la página con el objeto de usuario y no lo recuperamos, por lo tanto, no hay parpadeo.
En el segundo escenario, estamos usando la navegación del lado del cliente para llegar a nuestra página. En este caso, comprobamos si el usuario ya está cargado. Si el objeto de usuario es nulo, intentaremos recuperarlo.
¡Genial, tenemos el patrón de usuario universal en su lugar! Pero hay otro problema que podríamos enfrentar. ¿Qué sucede si el usuario abre una segunda pestaña o ventana y hace clic en el botón de cierre de sesión?
Abra la página de usuario universal en la demostración en dos pestañas o ventanas y pruébelo usted mismo. Si hace clic en cerrar sesión en una pestaña, luego regresa a la otra pestaña, verá que el objeto de usuario todavía está allí.
El patrón "recuperar usuario en el foco de la ventana" es una solución a este problema.
Afortunadamente, podemos usar el window.addEventListener
método para escuchar el focus
evento. De esta manera, recibimos una notificación cada vez que el usuario activa la pestaña o ventana.
Agreguemos un gancho a nuestra página para manejar eventos de ventana.
const windowHooks = (setIsWindowFocused: Dispatch<SetStateAction<"pristine" | "focused" | "blurred">>) => {
useEffect(() => {
const onFocus = () => {
setIsWindowFocused("focused");
};
const onBlur = () => {
setIsWindowFocused("blurred");
};
window.addEventListener('focus', onFocus);
window.addEventListener('blur', onBlur);
return () => {
window.removeEventListener('focus', onFocus);
window.removeEventListener('blur', onBlur);
};
}, []);
}
Notará que presentamos tres estados posibles para la acción "isWindowFocused": prístina, enfocada y borrosa. ¿Por qué tres estados? Imagina si tuviéramos solo dos estados, enfocado y borroso. En este caso, siempre tendríamos que disparar un evento de "enfoque", incluso si la ventana ya estaba enfocada. Al introducir el tercer estado (prístino), podemos evitar esto.
Otra observación importante que puede hacer es que estamos eliminando los detectores de eventos cuando se desmonta el componente. Esto es muy importante para evitar pérdidas de memoria.
Ok, hemos introducido un estado global para el foco de la ventana. Aprovechemos este estado para volver a buscar al usuario en el foco de la ventana agregando otro enlace:
useEffect(() => {
if (disableFetchUserClientSide) {
return;
}
if (disableFetchUserOnWindowFocus) {
return;
}
if (isWindowFocused !== "focused") {
return
}
const abort = new AbortController();
(async () => {
try {
const nextUser = await ctx.client.fetchUser(abort.signal);
if (JSON.stringify(nextUser) === JSON.stringify(user)) {
return;
}
setUser(nextUser);
} catch (e) {
}
})();
return () => {
abort.abort();
};
}, [isWindowFocused, disableFetchUserClientSide, disableFetchUserOnWindowFocus]);
Al agregar el isWindowFocused
estado a la lista de dependencias, este efecto se activará cada vez que cambie el enfoque de la ventana. Descartamos los eventos "prístinos" y "borrosos" y solo activamos una búsqueda de usuario si la ventana está enfocada.
Además, nos aseguramos de que solo activemos un estado setState para el usuario si realmente cambió. De lo contrario, podríamos activar renderizaciones o recuperaciones innecesarias.
¡Excelente! Nuestra aplicación ahora puede manejar la autenticación en varios escenarios. Esa es una gran base para pasar a los ganchos reales de obtención de datos.
El primer gancho de obtención de datos que veremos es la consulta del lado del cliente . Puede abrir la página de demostración (http://localhost:3000/patterns/client-side-query) en su navegador para familiarizarse con ella.
const data = useQuery.CountryWeather({
input: {
code: "DE",
},
});
Entonces, ¿qué hay detrás useQuery.CountryWeather
? ¡Echemos un vistazo!
function useQueryContextWrapper<Input, Data, Role>(wunderGraphContext: Context<WunderGraphContextProperties<Role>>, query: QueryProps, args?: InternalQueryArgsWithInput<Input>): {
result: QueryResult<Data>;
} {
const {client} = useContext(wunderGraphContext);
const cacheKey = client.cacheKey(query, args);
const [statefulArgs, setStatefulArgs] = useState<InternalQueryArgsWithInput<Input> | undefined>(args);
const [queryResult, setQueryResult] = useState<QueryResult<Data> | undefined>({status: "none"});
useEffect(() => {
if (lastCacheKey === "") {
setLastCacheKey(cacheKey);
return;
}
if (lastCacheKey === cacheKey) {
return;
}
setLastCacheKey(cacheKey);
setStatefulArgs(args);
setInvalidate(invalidate + 1);
}, [cacheKey]);
useEffect(() => {
const abort = new AbortController();
setQueryResult({status: "loading"});
(async () => {
const result = await client.query(query, {
...statefulArgs,
abortSignal: abort.signal,
});
setQueryResult(result as QueryResult<Data>);
})();
return () => {
abort.abort();
setQueryResult({status: "cancelled"});
}
}, [invalidate]);
return {
result: queryResult as QueryResult<Data>,
}
}
Vamos a explicar lo que está pasando aquí. Primero, tomamos el cliente que se está inyectando a través de React.Context. Luego calculamos una clave de caché para la consulta y los argumentos. Esta cacheKey nos ayuda a determinar si necesitamos volver a obtener los datos.
El estado inicial de la operación se establece en {status: "none"}
. Cuando se activa la primera obtención, el estado se establece en "loading"
. Cuando finaliza la búsqueda, el estado se establece en "success"
o "error"
. Si el componente que envuelve este gancho se está desmontando, el estado se establece en "cancelled"
.
Aparte de eso, nada especial está sucediendo aquí. La recuperación solo ocurre cuando se activa useEffect. Esto significa que no podemos ejecutar la búsqueda en el servidor. React.Hooks no se ejecuta en el servidor.
Si observa la demostración, notará que vuelve a parpadear. Esto se debe a que no estamos procesando el componente en el servidor. ¡Mejoremos esto!
Para ejecutar consultas no solo en el cliente sino también en el servidor, debemos aplicar algunos cambios a nuestros ganchos.
Primero actualicemos el useQuery
gancho.
function useQueryContextWrapper<Input, Data, Role>(wunderGraphContext: Context<WunderGraphContextProperties<Role>>, query: QueryProps, args?: InternalQueryArgsWithInput<Input>): {
result: QueryResult<Data>;
} {
const {ssrCache, client, isWindowFocused, refetchMountedOperations, user} = useContext(wunderGraphContext);
const isServer = typeof window === 'undefined';
const ssrEnabled = args?.disableSSR !== true && args?.lazy !== true;
const cacheKey = client.cacheKey(query, args);
if (isServer) {
if (ssrEnabled) {
if (ssrCache[cacheKey]) {
return {
result: ssrCache[cacheKey] as QueryResult<Data>,
}
}
const promise = client.query(query, args);
ssrCache[cacheKey] = promise;
throw promise;
} else {
ssrCache[cacheKey] = {
status: "none",
};
return {
result: ssrCache[cacheKey] as QueryResult<Data>,
}
}
}
const [invalidate, setInvalidate] = useState<number>(0);
const [statefulArgs, setStatefulArgs] = useState<InternalQueryArgsWithInput<Input> | undefined>(args);
const [lastCacheKey, setLastCacheKey] = useState<string>("");
const [queryResult, setQueryResult] = useState<QueryResult<Data> | undefined>(ssrCache[cacheKey] as QueryResult<Data> || {status: "none"});
useEffect(() => {
if (lastCacheKey === "") {
setLastCacheKey(cacheKey);
return;
}
if (lastCacheKey === cacheKey) {
return;
}
setLastCacheKey(cacheKey);
setStatefulArgs(args);
if (args?.debounceMillis !== undefined) {
setDebounce(prev => prev + 1);
return;
}
setInvalidate(invalidate + 1);
}, [cacheKey]);
useEffect(() => {
setQueryResult({status: "loading"});
(async () => {
const result = await client.query(query, {
...statefulArgs,
abortSignal: abort.signal,
});
setQueryResult(result as QueryResult<Data>);
})();
return () => {
abort.abort();
setQueryResult({status: "cancelled"});
}
}, [invalidate]);
return {
result: queryResult as QueryResult<Data>,
}
}
Ahora hemos actualizado el enlace useQuery para verificar si estamos en el servidor o no. Si estamos en el servidor, verificaremos si los datos ya se resolvieron para la clave de caché generada. Si los datos fueron resueltos, los devolveremos. De lo contrario, usaremos el cliente para ejecutar la consulta mediante una Promesa. Pero hay un problema. No se nos permite ejecutar código asíncrono mientras se renderiza en el servidor. Entonces, en teoría, no podemos "esperar" a que se resuelva la promesa.
En cambio, tenemos que usar un truco. Necesitamos "suspender" el renderizado. Podemos hacerlo "lanzando" la promesa que acabamos de crear.
Imagine que estamos renderizando el componente envolvente en el servidor. Lo que podríamos hacer es envolver el proceso de renderizado de cada componente en un bloque try/catch. Si uno de esos componentes arroja una promesa, podemos capturarlo, esperar hasta que se resuelva la promesa y luego volver a procesar el componente.
Una vez que se resuelve la promesa, podemos llenar la clave de caché con el resultado. De esta manera, podemos devolver los datos inmediatamente cuando "intentamos" renderizar el componente por segunda vez. Con este método, podemos movernos por el árbol de componentes y ejecutar todas las consultas que están habilitadas para la representación del lado del servidor.
Quizás se pregunte cómo implementar este método de prueba/captura. Por suerte, no tenemos que empezar de cero. Hay una biblioteca llamada react-ssr-prepass que podemos usar para hacer esto.
Apliquemos esto a nuestra getInitialProps
función:
WithWunderGraph.getInitialProps = async (ctx: NextPageContext) => {
const pageProps = (Page as NextPage).getInitialProps ? await (Page as NextPage).getInitialProps!(ctx as any) : {};
const ssrCache: { [key: string]: any } = {};
if (typeof window !== 'undefined') {
// we're on the client
// no need to do all the SSR stuff
return {...pageProps, ssrCache};
}
const cookieHeader = ctx.req?.headers.cookie;
if (typeof cookieHeader === "string") {
defaultContextProperties.client.setExtraHeaders({
Cookie: cookieHeader,
});
}
let ssrUser: User<Role> | null = null;
if (options?.disableFetchUserServerSide !== true) {
try {
ssrUser = await defaultContextProperties.client.fetchUser();
} catch (e) {
}
}
const AppTree = ctx.AppTree;
const App = createElement(wunderGraphContext.Provider, {
value: {
...defaultContextProperties,
user: ssrUser,
},
}, createElement(AppTree, {
pageProps: {
...pageProps,
},
ssrCache,
user: ssrUser
}));
await ssrPrepass(App);
const keys = Object.keys(ssrCache).filter(key => typeof ssrCache[key].then === 'function').map(key => ({
key,
value: ssrCache[key]
})) as { key: string, value: Promise<any> }[];
if (keys.length !== 0) {
const promises = keys.map(key => key.value);
const results = await Promise.all(promises);
for (let i = 0; i < keys.length; i++) {
const key = keys[i].key;
ssrCache[key] = results[i];
}
}
return {...pageProps, ssrCache, user: ssrUser};
};
El ctx
objeto no solo contiene el req
objeto sino también los AppTree
objetos. Usando el AppTree
objeto, podemos construir todo el árbol de componentes e inyectar nuestro proveedor de contexto, el ssrCache
objeto y el user
objeto.
Luego podemos usar la ssrPrepass
función para atravesar el árbol de componentes y ejecutar todas las consultas que están habilitadas para la representación del lado del servidor. Después de hacerlo, extraemos los resultados de todas las Promesas y completamos el ssrCache
objeto. Finalmente, devolvemos el pageProps
objeto y el ssrCache
objeto así como el user
objeto.
¡Fantástico! ¡Ahora podemos aplicar la representación del lado del servidor a nuestro enlace useQuery!
Vale la pena mencionar que hemos desvinculado por completo la representación del lado del servidor de tener que implementarla getServerSideProps
en nuestro Page
componente. Esto tiene algunos efectos que es importante discutir.
Primero, hemos resuelto el problema de que tenemos que declarar nuestras dependencias de datos en getServerSideProps
. Somos libres de colocar nuestros ganchos useQuery en cualquier parte del árbol de componentes, siempre se ejecutarán.
Por otro lado, este enfoque tiene la desventaja de que esta página no estará optimizada estáticamente. En su lugar, la página siempre se procesará en el servidor, lo que significa que debe haber un servidor ejecutándose para servir la página. Otro enfoque sería crear una página renderizada estáticamente, que se puede servir completamente desde un CDN.
Dicho esto, asumimos en esta guía que su objetivo es ofrecer contenido dinámico que cambia según el usuario. En este escenario, la representación estática de la página no será una opción, ya que no tenemos ningún contexto de usuario al obtener los datos.
Es genial lo que hemos logrado hasta ahora. Pero, ¿qué debería pasar si el usuario deja la ventana por un tiempo y vuelve? ¿Es posible que los datos que hemos obtenido en el pasado estén desactualizados? Si es así, ¿cómo podemos hacer frente a esta situación? ¡Al siguiente patrón!
Afortunadamente, ya implementamos un objeto de contexto global para propagar los tres estados de enfoque de ventana diferentes: prístino, borroso y enfocado.
Aprovechemos el estado "enfocado" para activar una recuperación de la consulta.
Recuerde que estábamos usando el contador "invalidar" para activar una recuperación de la consulta. Podemos agregar un nuevo efecto para aumentar este contador siempre que la ventana esté enfocada.
useEffect(() => {
if (!refetchOnWindowFocus) {
return;
}
if (isWindowFocused !== "focused") {
return;
}
setInvalidate(prev => prev + 1);
}, [refetchOnWindowFocus, isWindowFocused]);
¡Eso es todo! Descartamos todos los eventos si refetchOnWindowFocus se establece en falso o si la ventana no está enfocada. De lo contrario, aumentamos el contador de invalidaciones y activamos una nueva búsqueda de la consulta.
Si está siguiendo la demostración, eche un vistazo a la página de refetch-query-on-window-focus .
El enlace, incluida la configuración, se ve así:
const data = useQuery.CountryWeather({
input: {
code: "DE",
},
disableSSR: true,
refetchOnWindowFocus: true,
});
¡Eso fue rápido! Pasemos al siguiente patrón, carga diferida.
Como se discutió en el enunciado del problema, algunas de nuestras operaciones deben ejecutarse solo después de un evento específico. Hasta entonces, la ejecución debe ser aplazada.
Echemos un vistazo a la página de consulta diferida .
const [args,setArgs] = useState<QueryArgsWithInput<CountryWeatherInput>>({
input: {
code: "DE",
},
lazy: true,
});
Establecer perezoso en verdadero configura el gancho para que sea "perezoso". Ahora, veamos la implementación:
useEffect(() => {
if (lazy && invalidate === 0) {
setQueryResult({
status: "lazy",
});
return;
}
const abort = new AbortController();
setQueryResult({status: "loading"});
(async () => {
const result = await client.query(query, {
...statefulArgs,
abortSignal: abort.signal,
});
setQueryResult(result as QueryResult<Data>);
})();
return () => {
abort.abort();
setQueryResult({status: "cancelled"});
}
}, [invalidate]);
const refetch = useCallback((args?: InternalQueryArgsWithInput<Input>) => {
if (args !== undefined) {
setStatefulArgs(args);
}
setInvalidate(prev => prev + 1);
}, []);
Cuando este hook se ejecuta por primera vez, lazy se establecerá en true y invalidate se establecerá en 0. Esto significa que el hook de efecto regresará temprano y establecerá el resultado de la consulta en "lazy". No se ejecuta una búsqueda en este escenario.
Si queremos ejecutar la consulta, tenemos que aumentar la invalidación en 1. Podemos hacerlo llamando refetch
al gancho useQuery.
¡Eso es todo! La carga diferida ahora está implementada.
Pasemos al siguiente problema: eliminar las entradas de los usuarios para no obtener la consulta con demasiada frecuencia.
Digamos que el usuario quiere obtener el clima de una ciudad específica. Mi ciudad natal es "Frankfurt am Main", justo en el centro de Alemania. Ese término de búsqueda tiene 17 caracteres. ¿Con qué frecuencia debemos obtener la consulta mientras el usuario está escribiendo? 17 veces? ¿Una vez? ¿Quizás dos veces?
La respuesta estará en algún punto intermedio, pero definitivamente no es 17 veces. Entonces, ¿cómo podemos implementar este comportamiento? Echemos un vistazo a la implementación del gancho useQuery.
useEffect(() => {
if (debounce === 0) {
return;
}
const cancel = setTimeout(() => {
setInvalidate(prev => prev + 1);
}, args?.debounceMillis || 0);
return () => clearTimeout(cancel);
}, [debounce]);
useEffect(() => {
if (lastCacheKey === "") {
setLastCacheKey(cacheKey);
return;
}
if (lastCacheKey === cacheKey) {
return;
}
setLastCacheKey(cacheKey);
setStatefulArgs(args);
if (args?.debounceMillis !== undefined) {
setDebounce(prev => prev + 1);
return;
}
setInvalidate(invalidate + 1);
}, [cacheKey]);
Primero echemos un vistazo al segundo useEffect, el que tiene cacheKey como dependencia. Puede ver que antes de aumentar el contador de invalidaciones, verificamos si los argumentos de la operación contienen una propiedad debounceMillis. Si es así, no aumentamos inmediatamente el contador de invalidaciones. En su lugar, aumentamos el contador de rebotes.
Aumentar el contador de rebote activará el primer useEffect, ya que el contador de rebote es una dependencia. Si el contador de rebotes es 0, que es el valor inicial, regresamos inmediatamente, ya que no hay nada que hacer. De lo contrario, iniciamos un temporizador usando setTimeout. Una vez que se activa el tiempo de espera, aumentamos el contador de invalidaciones.
Lo especial del efecto que usa setTimeout es que estamos aprovechando la función de retorno del gancho del efecto para borrar el tiempo de espera. Lo que esto significa es que si el usuario escribe más rápido que el tiempo de rebote, el temporizador siempre se borra y el contador de invalidaciones no aumenta. Solo cuando ha pasado el tiempo completo de rebote, se incrementa el contador de invalidaciones.
Veo a menudo que los desarrolladores usan setTimeout pero se olvidan de manejar el objeto que regresa. No manejar el valor de retorno de setTimeout podría provocar pérdidas de memoria, ya que también es posible que el componente React adjunto se desmonte antes de que se active el tiempo de espera.
Si está interesado en jugar, diríjase a la demostración e intente escribir diferentes términos de búsqueda utilizando varios tiempos de rebote.
¡Estupendo! Tenemos una buena solución para contrarrestar las entradas de los usuarios. Veamos ahora las operaciones que requieren que el usuario esté autenticado. Comenzaremos con una consulta protegida del lado del servidor.
Digamos que estamos representando un tablero que requiere que el usuario esté autenticado. El tablero también mostrará datos específicos del usuario. ¿Cómo podemos implementar esto? Nuevamente, tenemos que modificar el gancho useQuery.
const {ssrCache, client, isWindowFocused, refetchMountedOperations, user} = useContext(wunderGraphContext);
const isServer = typeof window === 'undefined';
const ssrEnabled = args?.disableSSR !== true && args?.lazy !== true;
const cacheKey = client.cacheKey(query, args);
if (isServer) {
if (query.requiresAuthentication && user === null) {
ssrCache[cacheKey] = {
status: "requires_authentication"
};
return {
result: ssrCache[cacheKey] as QueryResult<Data>,
refetch: () => {
},
};
}
if (ssrEnabled) {
if (ssrCache[cacheKey]) {
return {
result: ssrCache[cacheKey] as QueryResult<Data>,
refetch: () => Promise.resolve(ssrCache[cacheKey] as QueryResult<Data>),
}
}
const promise = client.query(query, args);
ssrCache[cacheKey] = promise;
throw promise;
} else {
ssrCache[cacheKey] = {
status: "none",
};
return {
result: ssrCache[cacheKey] as QueryResult<Data>,
refetch: () => ({}),
}
}
}
Como discutimos en el patrón 2, Usuario del lado del servidor, ya implementamos alguna lógica para obtener el objeto del usuario getInitialProps
e inyectarlo en el contexto. También inyectamos la cookie de usuario en el cliente, que también se inyecta en el contexto. Juntos, estamos listos para implementar la consulta protegida del lado del servidor.
Si estamos en el servidor, comprobamos si la consulta requiere autenticación. Esta es información estática que se define en los metadatos de la consulta. Si el objeto de usuario es nulo, lo que significa que el usuario no está autenticado, devolvemos un resultado con el estado "requires_authentication". De lo contrario, avanzamos y lanzamos una promesa o devolvemos el resultado del caché.
Si va a la consulta protegida del lado del servidor en la demostración, puede jugar con esta implementación y ver cómo se comporta cuando inicia y cierra sesión.
Eso es todo, sin magia. Eso no fue demasiado complicado, ¿verdad? Bueno, el servidor no permite ganchos, lo que hace que la lógica sea mucho más fácil. Veamos ahora lo que se requiere para implementar la misma lógica en el cliente.
Para implementar la misma lógica para el cliente, necesitamos modificar el enlace useQuery una vez más.
useEffect(() => {
if (query.requiresAuthentication && user === null) {
setQueryResult({
status: "requires_authentication",
});
return;
}
if (lazy && invalidate === 0) {
setQueryResult({
status: "lazy",
});
return;
}
const abort = new AbortController();
if (queryResult?.status === "ok") {
setQueryResult({...queryResult, refetching: true});
} else {
setQueryResult({status: "loading"});
}
(async () => {
const result = await client.query(query, {
...statefulArgs,
abortSignal: abort.signal,
});
setQueryResult(result as QueryResult<Data>);
})();
return () => {
abort.abort();
setQueryResult({status: "cancelled"});
}
}, [invalidate, user]);
Como puede ver, ahora hemos agregado el objeto de usuario a las dependencias del efecto. Si la consulta requiere autenticación, pero el objeto de usuario es nulo, establecemos el resultado de la consulta en "requires_authentication" y regresamos antes, no se realiza ninguna búsqueda. Si pasamos esta verificación, la consulta se activa como de costumbre.
Hacer que el objeto del usuario dependa del efecto de búsqueda también tiene dos buenos efectos secundarios.
Digamos que una consulta requiere que el usuario esté autenticado, pero actualmente no lo está. El resultado de la consulta inicial es "requires_authentication". Si el usuario ahora inicia sesión, el objeto de usuario se actualiza a través del objeto de contexto. Como el objeto de usuario es una dependencia del efecto de búsqueda, todas las consultas ahora se activan nuevamente y el resultado de la consulta se actualiza.
Por otro lado, si una consulta requiere que el usuario esté autenticado y el usuario acaba de cerrar sesión, invalidaremos automáticamente todas las consultas y estableceremos los resultados en "requires_authentication".
¡Excelente! Ahora hemos implementado el patrón de consulta protegido del lado del cliente. Pero ese no es todavía el resultado ideal.
Si está utilizando consultas protegidas del lado del servidor, la navegación del lado del cliente no se maneja correctamente. Por otro lado, si solo usamos consultas protegidas del lado del cliente, siempre volveremos a tener el desagradable parpadeo.
Para resolver estos problemas, tenemos que juntar ambos patrones, lo que nos lleva al patrón de consulta protegido universal.
Este patrón no requiere ningún cambio adicional ya que ya hemos implementado toda la lógica. Todo lo que tenemos que hacer es configurar nuestra página para activar el patrón de consulta protegido universal.
Aquí está el código de la página de consulta protegida universal :
const UniversalProtectedQuery = () => {
const {user,login,logout} = useWunderGraph();
const data = useQuery.ProtectedWeather({
input: {
city: "Berlin",
},
});
return (
<div>
<h1>Universal Protected Query</h1>
<p>{JSON.stringify(user)}</p>
<p>{JSON.stringify(data)}</p>
<button onClick={() => login(AuthProviders.github)}>Login</button>
<button onClick={() => logout()}>Logout</button>
</div>
)
}
export default withWunderGraph(UniversalProtectedQuery);
Juegue con la demostración y vea cómo se comporta cuando inicia y cierra sesión. También intente actualizar la página o use la navegación del lado del cliente.
Lo bueno de este patrón es lo simple que es la implementación real de la página. El gancho de consulta "ProtectedWeather" abstrae toda la complejidad del manejo de la autenticación, tanto del lado del cliente como del lado del servidor.
Correcto, hemos dedicado mucho tiempo a las consultas hasta ahora, ¿qué pasa con las mutaciones? Comencemos con una mutación desprotegida, una que no requiere autenticación. Verá que los enlaces de mutación son mucho más fáciles de implementar que los enlaces de consulta.
function useMutationContextWrapper<Role, Input = never, Data = never>(wunderGraphContext: Context<WunderGraphContextProperties<Role>>, mutation: MutationProps): {
result: MutationResult<Data>;
mutate: (args?: InternalMutationArgsWithInput<Input>) => Promise<MutationResult<Data>>;
} {
const {client, user} = useContext(wunderGraphContext);
const [result, setResult] = useState<MutationResult<Data>>(mutation.requiresAuthentication && user === null ? {status: "requires_authentication"} : {status: "none"});
const mutate = useCallback(async (args?: InternalMutationArgsWithInput<Input>): Promise<MutationResult<Data>> => {
setResult({status: "loading"});
const result = await client.mutate(mutation, args);
setResult(result as any);
return result as any;
}, []);
return {
result,
mutate
}
}
Las mutaciones no se activan automáticamente. Esto significa que no estamos usando useEffect para desencadenar la mutación. En su lugar, estamos aprovechando el enlace useCallback para crear una función de "mutación" a la que se puede llamar.
Una vez llamado, establecemos el estado del resultado en "cargando" y luego llamamos a la mutación. Cuando finaliza la mutación, establecemos el estado del resultado en el resultado de la mutación. Esto puede ser un éxito o un fracaso. Finalmente, devolvemos tanto el resultado como la función de mutación.
Echa un vistazo a la página de mutaciones sin protección si quieres jugar con este patrón.
Esto fue bastante sencillo. Agreguemos algo de complejidad agregando autenticación.
function useMutationContextWrapper<Role, Input = never, Data = never>(wunderGraphContext: Context<WunderGraphContextProperties<Role>>, mutation: MutationProps): {
result: MutationResult<Data>;
mutate: (args?: InternalMutationArgsWithInput<Input>) => Promise<MutationResult<Data>>;
} {
const {client, user} = useContext(wunderGraphContext);
const [result, setResult] = useState<MutationResult<Data>>(mutation.requiresAuthentication && user === null ? {status: "requires_authentication"} : {status: "none"});
const mutate = useCallback(async (args?: InternalMutationArgsWithInput<Input>): Promise<MutationResult<Data>> => {
if (mutation.requiresAuthentication && user === null) {
return {status: "requires_authentication"}
}
setResult({status: "loading"});
const result = await client.mutate(mutation, args);
setResult(result as any);
return result as any;
}, [user]);
useEffect(() => {
if (!mutation.requiresAuthentication) {
return
}
if (user === null) {
if (result.status !== "requires_authentication") {
setResult({status: "requires_authentication"});
}
return;
}
if (result.status !== "none") {
setResult({status: "none"});
}
}, [user]);
return {
result,
mutate
}
}
De manera similar al patrón de consulta protegida, estamos inyectando el objeto de usuario del contexto en la devolución de llamada. Si la mutación requiere autenticación, verificamos si el usuario es nulo. Si el usuario es nulo, establecemos el resultado en "requires_authentication" y regresamos antes.
Además, agregamos un efecto para verificar si el usuario es nulo. Si el usuario es nulo, establecemos el resultado en "requires_authentication". Hicimos esto para que las mutaciones cambien automáticamente al estado "requires_authentication" o "ninguno", dependiendo de si el usuario está autenticado o no. De lo contrario, primero tendría que llamar a la mutación para darse cuenta de que no es posible llamar a la mutación. Creo que nos brinda una mejor experiencia de desarrollador cuando está claro por adelantado si la mutación es posible o no.
Muy bien, las mutaciones protegidas ya están implementadas. Quizás se pregunte por qué no hay una sección sobre mutaciones del lado del servidor, protegidas o no. Eso es porque las mutaciones siempre se desencadenan por la interacción del usuario. Por lo tanto, no es necesario que implementemos nada en el servidor.
Dicho esto, queda un problema con las mutaciones, ¡los efectos secundarios! ¿Qué sucede si hay una dependencia entre una lista de tareas y una mutación que cambia las tareas? ¡Hagamos que suceda!
Para que esto funcione, necesitamos cambiar tanto la devolución de llamada de mutación como el gancho de consulta. Comencemos con la devolución de llamada de mutación.
const {client, setRefetchMountedOperations, user} = useContext(wunderGraphContext);
const mutate = useCallback(async (args?: InternalMutationArgsWithInput<Input>): Promise<MutationResult<Data>> => {
if (mutation.requiresAuthentication && user === null) {
return {status: "requires_authentication"}
}
setResult({status: "loading"});
const result = await client.mutate(mutation, args);
setResult(result as any);
if (result.status === "ok" && args?.refetchMountedOperationsOnSuccess === true) {
setRefetchMountedOperations(prev => prev + 1);
}
return result as any;
}, [user]);
Nuestro objetivo es invalidar todas las consultas montadas actualmente cuando una mutación es exitosa. Podemos hacerlo introduciendo otro objeto de estado global que se almacena y propaga a través del contexto React. Llamamos a este objeto de estado "refetchMountedOperationsOnSuccess", que es un contador simple. En caso de que nuestra devolución de llamada de mutación sea exitosa, queremos incrementar el contador. Esto debería ser suficiente para invalidar todas las consultas montadas actualmente.
El segundo paso es cambiar el gancho de consulta.
const {ssrCache, client, isWindowFocused, refetchMountedOperations, user} = useContext(wunderGraphContext);
useEffect(() => {
if (queryResult?.status === "lazy" || queryResult?.status === "none") {
return;
}
setInvalidate(prev => prev + 1);
}, [refetchMountedOperations]);
Ya debería estar familiarizado con el contador "invalidar". Ahora estamos agregando otro efecto para manejar el incremento del contador "refetchMountedOperations" que se inyectó desde el contexto. Quizás se pregunte por qué volvemos antes si el estado es "perezoso" o "ninguno".
En el caso de "lazy", sabemos que esta consulta aún no se ejecutó, y el desarrollador tiene la intención de ejecutarla solo cuando se active manualmente. Por lo tanto, nos saltamos las consultas perezosas y esperamos hasta que se activen manualmente.
En caso de "ninguno", se aplica la misma regla. Esto podría suceder, por ejemplo, si una consulta solo se procesa en el lado del servidor, pero hemos navegado a la página actual a través de la navegación del lado del cliente. En tal caso, no hay nada que podamos "invalidar", ya que la consulta aún no se ha ejecutado. Tampoco queremos desencadenar por accidente consultas que aún no se ejecutaron a través de un efecto secundario de mutación.
¿Quieres experimentar esto en acción? Dirígete a la página Refetch Mounted Operations on Mutation Success .
¡Frio! Hemos terminado con consultas y mutaciones. A continuación, veremos la implementación de ganchos para suscripciones.
Para implementar suscripciones, tenemos que crear un nuevo gancho dedicado:
function useSubscriptionContextWrapper<Input, Data, Role>(wunderGraphContext: Context<WunderGraphContextProperties<Role>>, subscription: SubscriptionProps, args?: InternalSubscriptionArgsWithInput<Input>): {
result: SubscriptionResult<Data>;
} {
const {ssrCache, client} = useContext(wunderGraphContext);
const cacheKey = client.cacheKey(subscription, args);
const [invalidate, setInvalidate] = useState<number>(0);
const [subscriptionResult, setSubscriptionResult] = useState<SubscriptionResult<Data> | undefined>(ssrCache[cacheKey] as SubscriptionResult<Data> || {status: "none"});
useEffect(() => {
if (subscriptionResult?.status === "ok") {
setSubscriptionResult({...subscriptionResult, streamState: "restarting"});
} else {
setSubscriptionResult({status: "loading"});
}
const abort = new AbortController();
client.subscribe(subscription, (response: SubscriptionResult<Data>) => {
setSubscriptionResult(response as any);
}, {
...args,
abortSignal: abort.signal
});
return () => {
abort.abort();
}
}, [invalidate]);
return {
result: subscriptionResult as SubscriptionResult<Data>
}
}
La implementación de este enlace es similar al enlace de consulta. Se activa automáticamente cuando se monta el componente envolvente, por lo que estamos usando el gancho "useEffect" nuevamente.
Es importante pasar una señal de cancelación al cliente para asegurarse de que la suscripción se cancela cuando se desmonta el componente. Además, queremos cancelar y reiniciar la suscripción cuando se incremente el contador de invalidaciones, similar al gancho de consulta.
Hemos omitido la autenticación por brevedad en este punto, pero puede suponer que es muy similar al gancho de consulta.
¿Quieres jugar con el ejemplo? Dirígete a la página de suscripción del lado del cliente .
Sin embargo, una cosa a tener en cuenta es que las suscripciones se comportan de manera diferente a las consultas. Las suscripciones son un flujo de datos que se actualiza continuamente. Esto significa que tenemos que pensar cuánto tiempo queremos mantener abierta la suscripción. ¿Debe permanecer abierto para siempre? ¿O podría darse el caso de que queramos parar y reanudar la suscripción?
Uno de esos casos es cuando el usuario desenfoca la ventana, lo que significa que ya no está usando activamente la aplicación.
Para detener la suscripción cuando el usuario desenfoca la ventana, debemos extender el gancho de suscripción:
function useSubscriptionContextWrapper<Input, Data, Role>(wunderGraphContext: Context<WunderGraphContextProperties<Role>>, subscription: SubscriptionProps, args?: InternalSubscriptionArgsWithInput<Input>): {
result: SubscriptionResult<Data>;
} {
const {ssrCache, client, isWindowFocused, refetchMountedOperations, user} = useContext(wunderGraphContext);
const isServer = typeof window === 'undefined';
const ssrEnabled = args?.disableSSR !== true;
const cacheKey = client.cacheKey(subscription, args);
const [stop, setStop] = useState(false);
const [invalidate, setInvalidate] = useState<number>(0);
const [stopOnWindowBlur] = useState(args?.stopOnWindowBlur === true);
const [subscriptionResult, setSubscriptionResult] = useState<SubscriptionResult<Data> | undefined>(ssrCache[cacheKey] as SubscriptionResult<Data> || {status: "none"});
useEffect(() => {
if (stop) {
if (subscriptionResult?.status === "ok") {
setSubscriptionResult({...subscriptionResult, streamState: "stopped"});
} else {
setSubscriptionResult({status: "none"});
}
return;
}
if (subscriptionResult?.status === "ok") {
setSubscriptionResult({...subscriptionResult, streamState: "restarting"});
} else {
setSubscriptionResult({status: "loading"});
}
const abort = new AbortController();
client.subscribe(subscription, (response: SubscriptionResult<Data>) => {
setSubscriptionResult(response as any);
}, {
...args,
abortSignal: abort.signal
});
return () => {
abort.abort();
}
}, [stop, refetchMountedOperations, invalidate, user]);
useEffect(() => {
if (!stopOnWindowBlur) {
return
}
if (isWindowFocused === "focused") {
setStop(false);
}
if (isWindowFocused === "blurred") {
setStop(true);
}
}, [stopOnWindowBlur, isWindowFocused]);
return {
result: subscriptionResult as SubscriptionResult<Data>
}
}
Para que esto funcione, introducimos una nueva variable con estado llamada "stop". El estado predeterminado será falso, pero cuando el usuario desenfoca la ventana, estableceremos el estado en verdadero. Si vuelven a entrar en la ventana (foco), estableceremos el estado de nuevo en falso. Si el desarrollador establece "stopOnWindowBlur" en falso, lo ignoraremos, lo que se puede configurar en el objeto "args" de las suscripciones.
Además, tenemos que agregar la variable de parada a las dependencias de suscripción. ¡Eso es todo! Es muy útil que hayamos manejado los eventos de la ventana globalmente, esto hace que todos los demás ganchos sean mucho más fáciles de implementar.
La mejor manera de experimentar la implementación es abrir la página de Suscripción del lado del cliente y mirar atentamente la pestaña de red en la consola de Chrome DevTools (o similar si está usando otro navegador).
Volviendo a uno de los problemas que hemos descrito inicialmente, todavía tenemos que dar una respuesta a la pregunta de cómo podemos implementar la representación del lado del servidor para las suscripciones, haciendo que el gancho de suscripciones sea "universal".
Quizás esté pensando que la representación del lado del servidor no es posible para las suscripciones. Quiero decir, ¿cómo debe renderizar el servidor un flujo de datos?
Si es un lector habitual de este blog, es posible que conozca nuestra Implementación de suscripción. Como describimos en otro blog , implementamos las suscripciones de GraphQL de una manera que es compatible con EventSource (SSE), así como con la API Fetch.
También hemos agregado una bandera especial a la implementación. El cliente puede establecer el parámetro de consulta "wg_subscribe_once" en verdadero. Lo que esto significa es que una suscripción, con este indicador establecido, es esencialmente una consulta.
Aquí está la implementación del cliente para obtener una consulta:
const params = this.queryString({
wg_variables: args?.input,
wg_api_hash: this.applicationHash,
wg_subscribe_once: args?.subscribeOnce,
});
const headers: Headers = {
...this.extraHeaders,
Accept: "application/json",
"WG-SDK-Version": this.sdkVersion,
};
const defaultOrCustomFetch = this.customFetch || globalThis.fetch;
const url = this.baseURL + "/" + this.applicationPath + "/operations/" + query.operationName + params;
const response = await defaultOrCustomFetch(url,
{
headers,
method: 'GET',
credentials: "include",
mode: "cors",
}
);
Tomamos las variables, un hash de la configuración y el indicador subscribeOnce y los codificamos en la cadena de consulta. Si se establece suscribirse una vez, está claro para el servidor que solo queremos el primer resultado de la suscripción.
Para brindarle una imagen completa, veamos también la implementación de las suscripciones del lado del cliente:
private subscribeWithSSE = <S extends SubscriptionProps, Input, Data>(subscription: S, cb: (response: SubscriptionResult<Data>) => void, args?: InternalSubscriptionArgs) => {
(async () => {
try {
const params = this.queryString({
wg_variables: args?.input,
wg_live: subscription.isLiveQuery ? true : undefined,
wg_sse: true,
wg_sdk_version: this.sdkVersion,
});
const url = this.baseURL + "/" + this.applicationPath + "/operations/" + subscription.operationName + params;
const eventSource = new EventSource(url, {
withCredentials: true,
});
eventSource.addEventListener('message', ev => {
const responseJSON = JSON.parse(ev.data);
// omitted for brevity
if (responseJSON.data) {
cb({
status: "ok",
streamState: "streaming",
data: responseJSON.data,
});
}
});
if (args?.abortSignal) {
args.abortSignal.addEventListener("abort", () => eventSource.close());
}
} catch (e: any) {
// omitted for brevity
}
})();
};
La implementación del cliente de suscripción es similar al cliente de consulta, excepto que usamos la API de EventSource con una devolución de llamada. Si EventSource no está disponible, recurrimos a Fetch API, pero mantendré la implementación fuera de la publicación del blog, ya que no agrega mucho valor adicional.
Lo único importante que debe quitar de esto es que agregamos un oyente a la señal de cancelación. Si el componente adjunto se desmonta o invalida, activará el evento de cancelación, que cerrará EventSource.
Tenga en cuenta que si estamos haciendo un trabajo asíncrono de cualquier tipo, siempre debemos asegurarnos de que manejamos la cancelación correctamente, de lo contrario, podríamos terminar con una pérdida de memoria.
Bien, ahora conoce la implementación del cliente de suscripción. Envolvamos al cliente con ganchos de suscripción fáciles de usar que se pueden usar tanto en el cliente como en el servidor.
const {ssrCache, client, isWindowFocused, refetchMountedOperations, user} = useContext(wunderGraphContext);
const isServer = typeof window === 'undefined';
const ssrEnabled = args?.disableSSR !== true;
const cacheKey = client.cacheKey(subscription, args);
if (isServer) {
if (ssrEnabled) {
if (ssrCache[cacheKey]) {
return {
result: ssrCache[cacheKey] as SubscriptionResult<Data>
}
}
const promise = client.query(subscription, {...args, subscribeOnce: true});
ssrCache[cacheKey] = promise;
throw promise;
} else {
ssrCache[cacheKey] = {
status: "none",
}
return {
result: ssrCache[cacheKey] as SubscriptionResult<Data>
}
}
}
De manera similar al enlace useQuery, agregamos una rama de código para la representación del lado del servidor. Si estamos en el servidor y aún no tenemos ningún dato, hacemos una solicitud de "consulta" con el indicador subscribeOnce establecido en verdadero. Como se describió anteriormente, una suscripción con el indicador subscribeOnce establecido en verdadero, solo devolverá el primer resultado, por lo que se comporta como una consulta. Es por eso que usamos client.query()
en lugar de client.subscribe()
.
Algunos comentarios en la publicación del blog sobre nuestra implementación de suscripción indicaron que no es tan importante hacer que las suscripciones sean sin estado. Espero que en este punto quede claro por qué hemos ido por este camino. La compatibilidad con Fetch acaba de aterrizar en NodeJS, e incluso antes de eso, hemos tenido node-fetch como un polyfill. Definitivamente sería posible iniciar suscripciones en el servidor usando WebSockets, pero en última instancia, creo que es mucho más fácil simplemente usar la API Fetch y no tener que preocuparse por las conexiones de WebSocket en el servidor.
La mejor manera de jugar con esta implementación es ir a la página de suscripción universal . Cuando actualice la página, eche un vistazo a la "vista previa" de la primera solicitud. Verá que la página vendrá renderizada por el servidor en comparación con la suscripción del lado del cliente. Una vez que el cliente se rehidrate, iniciará una suscripción por sí mismo para mantener actualizada la interfaz de usuario.
Eso fue mucho trabajo, pero aún no hemos terminado. Las suscripciones también deben protegerse mediante la autenticación, agreguemos algo de lógica al gancho de suscripción.
Notarás que es muy similar a un gancho de consulta normal.
const {ssrCache, client, isWindowFocused, refetchMountedOperations, user} = useContext(wunderGraphContext);
const [subscriptionResult, setSubscriptionResult] = useState<SubscriptionResult<Data> | undefined>(ssrCache[cacheKey] as SubscriptionResult<Data> || {status: "none"});
useEffect(() => {
if (subscription.requiresAuthentication && user === null) {
setSubscriptionResult({
status: "requires_authentication",
});
return;
}
if (stop) {
if (subscriptionResult?.status === "ok") {
setSubscriptionResult({...subscriptionResult, streamState: "stopped"});
} else {
setSubscriptionResult({status: "none"});
}
return;
}
if (subscriptionResult?.status === "ok") {
setSubscriptionResult({...subscriptionResult, streamState: "restarting"});
} else {
setSubscriptionResult({status: "loading"});
}
const abort = new AbortController();
client.subscribe(subscription, (response: SubscriptionResult<Data>) => {
setSubscriptionResult(response as any);
}, {
...args,
abortSignal: abort.signal
});
return () => {
abort.abort();
}
}, [stop, refetchMountedOperations, invalidate, user]);
Primero, tenemos que agregar el usuario como una dependencia para el efecto. Esto hará que el efecto se dispare cada vez que cambie el usuario. Luego, debemos verificar los metadatos de la suscripción y ver si requiere autenticación. Si lo hace, comprobamos si el usuario está logueado. Si el usuario está logueado, continuamos con la suscripción. Si el usuario no ha iniciado sesión, establecemos el resultado de la suscripción en "requires_authentication".
¡Eso es todo! ¡Suscripciones universales con reconocimiento de autenticación completadas! Echemos un vistazo a nuestro resultado final:
const ProtectedSubscription = () => {
const {login,logout,user} = useWunderGraph();
const data = useSubscription.ProtectedPriceUpdates();
return (
<div>
<p>{JSON.stringify(user)}</p>
<p style={{height: "8vh"}}>{JSON.stringify(data)}</p>
<button onClick={() => login(AuthProviders.github)}>Login</button>
<button onClick={() => logout()}>Logout</button>
</div>
)
}
export default withWunderGraph(ProtectedSubscription);
¿No es genial cómo podemos ocultar tanta complejidad detrás de una API simple? Todas estas cosas, como la autenticación, el enfoque y el desenfoque de la ventana, la representación del lado del servidor, la representación del lado del cliente, el paso de datos del servidor al cliente, la rehidratación adecuada del cliente, todo lo manejamos nosotros.
Además de eso, el cliente usa principalmente genéricos y está envuelto por una pequeña capa de código generado, lo que hace que todo el cliente sea completamente seguro. La seguridad tipográfica era uno de nuestros requisitos, si recuerdas.
Algunos clientes de API "pueden" tener seguridad de tipos. Otros le permiten agregar algún código adicional para que sean de tipo seguro. Con nuestro enfoque, un cliente genérico más tipos generados automáticamente, el cliente siempre tiene seguridad de tipos.
Es un manifiesto para nosotros que, hasta ahora, nadie nos ha pedido que agreguemos un cliente de JavaScript "puro". Nuestros usuarios parecen aceptar y apreciar que todo es seguro desde el primer momento. Creemos que la seguridad de tipos ayuda a los desarrolladores a cometer menos errores y a comprender mejor su código.
¿Quieres jugar tú mismo con suscripciones universales protegidas? Consulte la página de suscripción protegida de la demostración. No olvide consultar Chrome DevTools y la pestaña de red para obtener la mejor información.
Finalmente, hemos terminado con las suscripciones. Faltan dos patrones más y hemos terminado por completo.
El último patrón que vamos a cubrir es Live Queries. Las consultas en vivo son similares a las suscripciones en la forma en que se comportan en el lado del cliente. Donde difieren es en el lado del servidor.
Primero analicemos cómo funcionan las consultas en vivo en el servidor y por qué son útiles. Si un cliente se "suscribe" a una consulta en vivo, el servidor comenzará a sondear el servidor de origen en busca de cambios. Lo hará en un intervalo configurable, por ejemplo, cada segundo. Cuando el servidor recibe un cambio, analizará los datos y los comparará con el hash del último cambio. Si los hashes son diferentes, el servidor enviará los nuevos datos al cliente. Si los hashes son los mismos, sabemos que nada cambió, por lo que no enviamos nada al cliente.
¿Por qué y cuándo son útiles las consultas en vivo? En primer lugar, gran parte de la infraestructura existente no admite suscripciones. Agregar consultas en vivo en el nivel de la puerta de enlace significa que puede agregar capacidades de "tiempo real" a su infraestructura existente. Podría tener un backend de PHP heredado que ya no quiera tocar. Agregue consultas en vivo encima y su interfaz podrá recibir actualizaciones en tiempo real.
Quizás se esté preguntando por qué no simplemente hacer el sondeo desde el lado del cliente. El sondeo del lado del cliente podría generar muchas solicitudes al servidor. Imagínese si 10.000 clientes hacen una solicitud por segundo. Eso es 10.000 solicitudes por segundo. ¿Crees que tu servidor PHP heredado puede manejar ese tipo de carga?
¿Cómo pueden ayudar las consultas en vivo? 10.000 clientes se conectan a la puerta de enlace api y se suscriben a una consulta en vivo. Luego, la puerta de enlace puede agrupar todas las solicitudes, ya que esencialmente solicitan los mismos datos y realizar una sola solicitud al origen.
Al usar consultas en vivo, podemos reducir la cantidad de solicitudes al servidor de origen, según la cantidad de "flujos" que se usen.
Entonces, ¿cómo podemos implementar consultas en vivo en el cliente?
Eche un vistazo al envoltorio "generado" del cliente genérico para una de nuestras operaciones:
CountryWeather: (args: SubscriptionArgsWithInput<CountryWeatherInput>) =>
hooks.useSubscriptionWithInput<CountryWeatherInput, CountryWeatherResponseData, Role>(WunderGraphContext, {
operationName: "CountryWeather",
isLiveQuery: true,
requiresAuthentication: false,
})(args)
Mirando este ejemplo, puede notar algunas cosas. Primero, estamos usando el useSubscriptionWithInput
gancho. Esto indica que en realidad no tenemos que distinguir entre una suscripción y una consulta en vivo, al menos no desde la perspectiva del lado del cliente. La única diferencia es que estamos configurando la isLiveQuery
bandera en true
. Para las suscripciones, usamos el mismo enlace, pero establecemos la isLiveQuery
marca en false
.
Como ya implementamos el enlace de suscripción anterior, no se requiere código adicional para que las consultas en vivo funcionen.
Consulte la página de consulta en vivo de la demostración. Una cosa que puede notar es que este ejemplo tiene el desagradable parpadeo nuevamente, eso se debe a que no lo estamos renderizando del lado del servidor.
El patrón final y último que vamos a cubrir es Universal Live Queries. Las consultas en vivo universales son similares a las suscripciones, solo que más simples desde la perspectiva del lado del servidor. Para que el servidor inicie una suscripción, tiene que abrir una conexión WebSocket con el servidor de origen, hacer el protocolo de enlace, suscribirse, etc. Si necesitamos suscribirnos una vez con una consulta en vivo, simplemente estamos "sondeando" una vez , lo que significa que solo estamos haciendo una sola solicitud. Por lo tanto, las consultas en vivo son en realidad un poco más rápidas de iniciar en comparación con las suscripciones, al menos en la solicitud inicial.
¿Cómo podemos usarlos? Veamos un ejemplo de la demostración:
const UniversalLiveQuery = () => {
const data = useLiveQuery.CountryWeather({
input: {
code: "DE",
},
});
return (
<p>{JSON.stringify(data)}</p>
)
}
export default withWunderGraph(UniversalLiveQuery);
Eso es todo, ese es su flujo de datos meteorológicos para la capital de Alemania, Berlín, que se actualiza cada segundo.
Quizás se pregunte cómo obtuvimos los datos en primer lugar. Veamos la definición de la CountryWeather
operación:
query ($capital: String! @internal $code: ID!) {
countries_country(code: $code){
code
name
capital @export(as: "capital")
weather: _join @transform(get: "weather_getCityByName.weather") {
weather_getCityByName(name: $capital){
weather {
temperature {
actual
}
summary {
title
description
}
}
}
}
}
}
En realidad estamos uniendo datos de dos servicios dispares. Primero, estamos usando una API de países para obtener la capital de un país. Exportamos el campo a la variable capital
interna . $capital
Luego, usamos el _join
campo para combinar los datos del país con una API meteorológica. Finalmente, aplicamos la @transform
directiva para aplanar un poco la respuesta.
Es una consulta GraphQL normal y válida. En combinación con el patrón de consulta en vivo, ahora podemos transmitir en vivo el clima de cualquier capital de cualquier país. Genial, ¿no?
Al igual que todos los demás patrones, este también se puede probar en la demostración. ¡ Dirígete a la página de consulta universal en vivo y juega!
¡Eso es todo! ¡Hemos terminado! Espero que haya aprendido cómo puede crear ganchos de obtención de datos universales y conscientes de la autenticación.
Antes de que lleguemos al final de esta publicación, me gustaría ver enfoques y herramientas alternativos para implementar ganchos de obtención de datos.
Una de las principales desventajas de utilizar la representación del lado del servidor es que el cliente tiene que esperar hasta que el servidor haya terminado de representar la página. Dependiendo de la complejidad de la página, esto puede llevar un tiempo, especialmente si tiene que realizar muchas solicitudes encadenadas para obtener todos los datos necesarios para la página.
Una solución a este problema es generar estáticamente la página en el servidor. NextJS le permite implementar una getStaticProps
función asíncrona en la parte superior de cada página. Esta función se llama en el momento de la creación y es responsable de obtener todos los datos necesarios para la página. Si, al mismo tiempo, no adjunta una función getInitialProps
o getServerSideProps
a la página, NextJS considera que esta página es estática, lo que significa que no se requerirá ningún proceso de NodeJS para representar la página. En este escenario, la página se renderizará previamente en el momento de la compilación, lo que permitirá que una CDN la almacene en caché.
Esta forma de renderizar hace que la aplicación sea extremadamente rápida y fácil de alojar, pero también tiene inconvenientes.
Por un lado, una página estática no es específica del usuario. Eso es porque en tiempo de construcción, no hay contexto del usuario. Sin embargo, esto no es un problema para las páginas públicas. Es solo que no puede usar páginas específicas de usuario como tableros de esta manera.
Una compensación que se puede hacer es renderizar la página de forma estática y agregar contenido específico del usuario en el lado del cliente. Sin embargo, esto siempre generará parpadeo en el cliente, ya que la página se actualizará muy poco tiempo después del renderizado inicial. Por lo tanto, si está creando una aplicación que requiere que el usuario esté autenticado, es posible que desee utilizar la representación del lado del servidor en su lugar.
El segundo inconveniente de la generación de sitios estáticos es que el contenido puede quedar obsoleto si los datos subyacentes cambian. En ese caso, es posible que desee reconstruir la página. Sin embargo, la reconstrucción de toda la página puede llevar mucho tiempo y puede ser innecesaria si solo se necesita reconstruir unas pocas páginas. Afortunadamente, hay una solución a este problema: la regeneración estática incremental.
La regeneración estática incremental le permite invalidar páginas individuales y volver a procesarlas a pedido. Esto le brinda la ventaja de rendimiento de un sitio estático, pero elimina el problema del contenido obsoleto.
Dicho esto, esto todavía no resuelve el problema con la autenticación, pero no creo que esto sea de lo que se trata la generación de sitios estáticos.
Por nuestra parte, actualmente estamos analizando patrones en los que el resultado de una mutación podría desencadenar automáticamente una reconstrucción de página mediante ISR. Idealmente, esto podría ser algo que funcione de forma declarativa, sin tener que implementar una lógica personalizada.
Un problema con el que se puede encontrar con la representación del lado del servidor (pero también del lado del cliente) es que al atravesar el árbol de componentes, el servidor puede tener que crear una enorme cascada de consultas que dependen unas de otras. Si los componentes secundarios dependen de los datos de sus padres, es posible que se encuentre fácilmente con el problema N+1.
N+1 en este caso significa que obtiene una matriz de datos en un componente raíz y luego, para cada uno de los elementos de la matriz, tendrá que activar una consulta adicional en un componente secundario.
Tenga en cuenta que este problema no es específico del uso de GraphQL. GraphQL en realidad tiene una solución para resolverlo, mientras que las API REST sufren el mismo problema. La solución es usar fragmentos de GraphQL con un cliente que los admita adecuadamente.
Los creadores de GraphQL, Facebook/Meta, han creado una solución para este problema, se llama Relay Client.
Relay Client es una biblioteca que le permite especificar sus "Requisitos de datos" junto con los componentes a través de fragmentos de GraphQL. Aquí hay un ejemplo de cómo podría verse esto:
import type {UserComponent_user$key} from 'UserComponent_user.graphql';
const React = require('React');
const {graphql, useFragment} = require('react-relay');
type Props = {
user: UserComponent_user$key,
};
function UserComponent(props: Props) {
const data = useFragment(
graphql`
fragment UserComponent_user on User {
name
profile_picture(scale: 2) {
uri
}
}
`,
props.user,
);
return (
<>
<h1>{data.name}</h1>
<div>
<img src={data.profile_picture?.uri} />
</div>
</>
);
}
Si este fuera un componente anidado, el fragmento nos permite elevar nuestros requisitos de datos hasta el componente raíz. Esto significa que el componente raíz será capaz de obtener los datos de sus elementos secundarios, manteniendo la definición de requisitos de datos en los componentes secundarios.
Los fragmentos permiten un acoplamiento flexible entre los componentes principales y secundarios, al tiempo que permiten un proceso de obtención de datos más eficiente. Para muchos desarrolladores, esta es la razón real por la que usan GraphQL. No es que usen GraphQL porque quieran usar el lenguaje de consulta, es porque quieren aprovechar el poder del cliente de retransmisión.
Para nosotros, el Cliente de Relay es una gran fuente de inspiración. De hecho, creo que usar Relay es demasiado difícil. En nuestra próxima iteración, buscamos adoptar el enfoque de "elevación de fragmentos", pero nuestro objetivo es que sea más fácil de usar que el cliente de retransmisión.
Otro desarrollo que está ocurriendo en el mundo de React es la creación de React Suspense. Como ha visto anteriormente, ya estamos usando Suspense en el servidor. Al "lanzar" una promesa, podemos suspender la representación de un componente hasta que se resuelva la promesa. Esa es una excelente manera de manejar la obtención de datos asincrónicos en el servidor.
Sin embargo, también puede aplicar esta técnica en el cliente. El uso de Suspense en el cliente nos permite "renderizar mientras se obtiene" de una manera muy eficiente. Además, los clientes que admiten Suspense permiten una API más elegante para enlaces de obtención de datos. En lugar de tener que manejar estados de "carga" o "error" dentro del componente, el suspenso "empujará" estos estados al siguiente "límite de error" y los manejará allí. Este enfoque hace que el código dentro del componente sea mucho más legible, ya que solo maneja el "camino feliz".
Como ya admitimos Suspense en el servidor, puede estar seguro de que también agregaremos soporte al cliente en el futuro. Solo queremos descubrir la forma más idiomática de apoyar tanto a un cliente de suspenso como a uno que no lo es. De esta manera, los usuarios obtienen la libertad de elegir el estilo de programación que prefieran.
No somos los únicos que intentamos mejorar la experiencia de obtención de datos en NextJS. Por lo tanto, echemos un vistazo rápido a otras tecnologías y cómo se comparan con el enfoque que estamos proponiendo.
De hecho, nos hemos inspirado mucho en swr. Si observa los patrones que hemos implementado, verá que swr realmente nos ayudó a definir una excelente API de obtención de datos.
Hay algunas cosas en las que nuestro enfoque difiere del swr que vale la pena mencionar.
SWR es mucho más flexible y fácil de adoptar porque puede usarlo con cualquier backend. El enfoque que hemos adoptado, especialmente la forma en que manejamos la autenticación, requiere que también ejecute un backend de WunderGraph que proporcione la API que esperamos.
Por ejemplo, si está utilizando el cliente WunderGraph, esperamos que el backend sea una parte dependiente de OpenID Connect. El cliente swr, por otro lado, no hace tales suposiciones.
Personalmente, creo que con una biblioteca como swr, eventualmente obtendrá un resultado similar al que obtendría si estuviera usando el cliente WunderGraph en primer lugar. Es solo que ahora está manteniendo más código ya que tuvo que agregar lógica de autenticación.
La otra gran diferencia es la representación del lado del servidor. WunderGraph está cuidadosamente diseñado para eliminar cualquier parpadeo innecesario al cargar una aplicación que requiere autenticación. Los documentos de swr explican que esto no es un problema y que los usuarios están de acuerdo con cargar spinners en los tableros.
Creo que podemos hacerlo mejor que eso. Sé de paneles SaaS que tardan 15 segundos o más en cargar todos los componentes, incluido el contenido. Durante este período de tiempo, la interfaz de usuario no se puede usar en absoluto, porque sigue "moviendo" todo el contenido en el lugar correcto.
¿Por qué no podemos renderizar previamente todo el tablero y luego rehidratar al cliente? Si el HTML se representa de la manera correcta, se debe poder hacer clic en los enlaces incluso antes de que se cargue el cliente de JavaScript.
Si todo su "backend" cabe en el directorio "/api" de su aplicación NextJS, su mejor opción probablemente sea usar la biblioteca "swr". Combinado con NextAuthJS, esto puede ser una muy buena combinación.
Si, en cambio, está creando servicios dedicados para implementar API, un enfoque de "backend para frontend", como el que proponemos con WunderGraph, podría ser una mejor opción, ya que podemos eliminar muchos cierres de sesión repetitivos. de sus servicios y en el middleware.
Hablando de NextAuthJS, ¿por qué no simplemente agregar la autenticación directamente en su aplicación NextJS? La biblioteca está diseñada para resolver exactamente este problema, agregando autenticación a su aplicación NextJS con un mínimo esfuerzo.
Desde una perspectiva técnica, NextAuthJS sigue patrones similares a WunderGraph. Solo hay algunas diferencias en términos de la arquitectura general.
Si está creando una aplicación que nunca escalará más allá de un solo sitio web, probablemente pueda usar NextAuthJS. Sin embargo, si planea usar varios sitios web, herramientas cli, aplicaciones nativas o incluso conectar un backend, es mejor que use un enfoque diferente.
Déjame explicarte por qué.
La forma en que se implementa NextAuthJS es que en realidad se convierte en el "Emisor" del flujo de autenticación. Dicho esto, no es un emisor compatible con OpenID Connect, es una implementación personalizada. Entonces, si bien es fácil comenzar, en realidad está agregando una gran cantidad de deuda técnica al principio.
Supongamos que le gustaría agregar otro tablero o una herramienta cli o conectar un backend a sus API. Si estaba usando un emisor compatible con OpenID Connect, ya hay un flujo implementado para varios escenarios diferentes. Además, este proveedor de OpenID Connect solo está ligeramente acoplado a su aplicación NextJS. Hacer que su propia aplicación sea el emisor significa que debe volver a implementar y modificar su aplicación "frontend", cada vez que desee modificar el flujo de autenticación. Tampoco podrá usar flujos de autenticación estandarizados como el flujo de código con pkce o el flujo del dispositivo.
La autenticación debe gestionarse fuera de la propia aplicación. Recientemente anunciamos nuestra asociación con Cloud IAM , lo que hace que la configuración de un proveedor de OpenID Connect con WunderGraph como la parte de confianza sea cuestión de minutos.
Espero que se lo pongamos lo suficientemente fácil para que no tenga que crear sus propios flujos de autenticación.
La capa de obtención de datos y los ganchos son en realidad muy parecidos a WunderGraph. Creo que incluso estamos usando el mismo enfoque para la representación del lado del servidor en NextJS.
El trpc obviamente tiene muy poco que ver con GraphQL, en comparación con WunderGraph. Su historia sobre la autenticación tampoco es tan completa como WunderGraph.
Dicho esto, creo que Alex ha hecho un gran trabajo al construir trpc. Es menos obstinado que WunderGraph, lo que lo convierte en una excelente opción para diferentes escenarios.
Según tengo entendido, trpc funciona mejor cuando tanto el backend como el frontend usan TypeScript. WunderGraph toma un camino diferente. El término medio común para definir el contrato entre el cliente y el servidor es JSON-RPC, definido mediante JSON Schema. En lugar de simplemente importar los tipos de servidor al cliente, debe pasar por un proceso de generación de código con WunderGraph.
Esto significa que la configuración es un poco más compleja, pero no solo podemos admitir TypeScript como entorno de destino, sino cualquier otro lenguaje o tiempo de ejecución que admita JSON a través de HTTP.
Hay muchos otros clientes de GraphQL, como Apollo Client, urql y graphql-request. Lo que todos ellos tienen en común es que no suelen utilizar JSON-RPC como transporte.
Probablemente haya escrito esto en varias publicaciones de blog antes, pero enviar solicitudes de lectura a través de HTTP POST simplemente rompe Internet. Si no está cambiando las operaciones GraphQL, como el 99 % de todas las aplicaciones que usan un paso de compilación/transpilación, ¿por qué usar un cliente GraphQL que hace esto?
Clientes, navegadores, servidores de caché, servidores proxy y CDN, todos entienden los encabezados de control de caché y las etiquetas electrónicas. El popular cliente de obtención de datos NextJS "swr" tiene su nombre por una razón, porque swr significa "obsoleto mientras se revalida", que no es más que el patrón que aprovecha ETags para una invalidación de caché eficiente.
GraphQL es una gran abstracción para definir dependencias de datos. Pero cuando se trata de implementar aplicaciones a escala web, debemos aprovechar la infraestructura existente de la web. Lo que esto significa es esto: GraphQL es excelente durante el desarrollo, pero en la producción, deberíamos aprovechar los principios de REST tanto como podamos.
Crear buenos ganchos de obtención de datos para NextJS y React en general es un desafío. También hemos discutido que estamos llegando a soluciones algo diferentes si tomamos en cuenta la autenticación desde el principio. Personalmente, creo que agregar autenticación directamente en la capa API en ambos extremos, backend y frontend, hace que el enfoque sea mucho más limpio. Otro aspecto a tener en cuenta es dónde colocar la lógica de autenticación. Idealmente, no lo está implementando usted mismo, pero puede confiar en una implementación adecuada. Combinar OpenID Connect como emisor con una parte dependiente en su back-end-for-frontend (BFF) es una excelente manera de mantener las cosas desacopladas pero aún muy controlables.
Nuestro BFF todavía está creando y validando cookies, pero no es la fuente de la verdad. Siempre estamos delegando a Keycloak. Lo bueno de esta configuración es que puede cambiar fácilmente Keycloak por otra implementación, esa es la belleza de confiar en interfaces en lugar de implementaciones concretas.
Finalmente, espero poder convencerlo de que más tableros (SaaS) deberían adoptar la representación del lado del servidor. NextJS y WunderGraph hacen que sea tan fácil de implementar que vale la pena intentarlo.
Una vez más, si está interesado en jugar con una demostración, aquí está el repositorio: https://github.com/wundergraph/wundergraph-demo
1652517446
Las migas de pan son una herramienta de navegación del sitio web que permite a un usuario ver la "pila" de su página actual de cómo está anidada debajo de las páginas principales. Luego, los usuarios pueden regresar a una página principal haciendo clic en el enlace de ruta de navegación asociado. Estas "migajas" aumentan la experiencia del usuario de la aplicación, lo que facilita que los usuarios naveguen por páginas anidadas de manera eficiente y efectiva.
Las migas de pan son lo suficientemente populares como para que, si está creando un panel de control web o una aplicación, haya considerado agregarlas. Generar estos enlaces de migas de pan de manera eficiente y con el contexto apropiado es clave para una experiencia de usuario mejorada.
Construyamos un NextBreadcrumbs
componente React inteligente que analice la ruta actual y construya una pantalla de migas de pan dinámicas que pueda manejar rutas tanto estáticas como dinámicas de manera eficiente.
Mis proyectos generalmente giran en torno a Nextjs y MUI (anteriormente Material-UI), por lo que ese es el ángulo desde el que abordaré este problema, aunque la solución debería funcionar para cualquier aplicación relacionada con Nextjs.
Para empezar, nuestro NextBreadcrumbs
componente solo manejará rutas estáticas, lo que significa que nuestro proyecto solo tiene páginas estáticas definidas en el pages
directorio.
Los siguientes son ejemplos de rutas estáticas porque no contienen [
s y ]
s en los nombres de ruta, lo que significa que la estructura del directorio se alinea 1:1 exactamente con las URL esperadas que sirven.
pages/index.js
-->/
pages/about.js
-->/about
pages/my/super/nested/route.js
-->/my/super/nested/route
La solución se ampliará para manejar rutas dinámicas más adelante.
Podemos comenzar con el componente básico que usa el componente MUIBreadcrumbs
como línea de base.
import Breadcrumbs from '@mui/material/Breadcrumbs';
import * as React from 'react';
export default function NextBreadcrumbs() {
return (
<Breadcrumbs aria-label="breadcrumb" />
);
}
Lo anterior crea la estructura básica del NextBreadcrumbs
componente React, importa las dependencias correctas y genera un Breadcrumbs
componente MUI vacío.
Luego podemos agregar los next/router
ganchos, lo que nos permitirá construir las migas de pan a partir de la ruta actual.
También creamos un Crumb
componente que se usará para representar cada enlace. Este es un componente bastante tonto por ahora, excepto que mostrará texto normal en lugar de un enlace para la última ruta de navegación.
En una situación como /settings/notifications
, se representaría de la siguiente manera:
Home (/ link) > Settings (/settings link) > Notifications (no link)
Esto se debe a que el usuario ya está en la última página de migas de pan, por lo que no es necesario vincular a la misma página. Todas las demás migajas se representan como enlaces en los que se puede hacer clic.
import Breadcrumbs from '@mui/material/Breadcrumbs';
import Link from '@mui/material/Link';
import Typography from '@mui/material/Typography';
import { useRouter } from 'next/router';
import React from 'react';
export default function NextBreadcrumbs() {
// Gives us ability to load the current route details
const router = useRouter();
return (
<Breadcrumbs aria-label="breadcrumb" />
);
}
// Each individual "crumb" in the breadcrumbs list
function Crumb({ text, href, last=false }) {
// The last crumb is rendered as normal text since we are already on the page
if (last) {
return <Typography color="text.primary">{text}</Typography>
}
// All other crumbs will be rendered as links that can be visited
return (
<Link underline="hover" color="inherit" href={href}>
{text}
</Link>
);
}
Con este diseño, podemos volver a sumergirnos en el NextBreadcrumbs
componente para generar las migas de pan a partir de la ruta. Parte del código existente comenzará a omitirse para mantener las piezas de código más pequeñas. El ejemplo completo se muestra a continuación.
Generaremos una lista de objetos de migas de pan que contienen la información que cada Crumb
elemento debe representar. Cada ruta de navegación se creará analizando la propiedad del enrutador NextjsasPath
, que es una cadena que contiene la ruta como se muestra en la barra de URL del navegador.
Quitaremos todos los parámetros de consulta, como ?query=value
, de la URL para que el proceso de creación de la ruta de navegación sea más sencillo.
export default function NextBreadcrumbs() {
// Gives us ability to load the current route details
const router = useRouter();
function generateBreadcrumbs() {
// Remove any query parameters, as those aren't included in breadcrumbs
const asPathWithoutQuery = router.asPath.split("?")[0];
// Break down the path between "/"s, removing empty entities
// Ex:"/my/nested/path" --> ["my", "nested", "path"]
const asPathNestedRoutes = asPathWithoutQuery.split("/")
.filter(v => v.length > 0);
// Iterate over the list of nested route parts and build
// a "crumb" object for each one.
const crumblist = asPathParts.map((subpath, idx) => {
// We can get the partial nested route for the crumb
// by joining together the path parts up to this point.
const href = "/" + asPathNestedRoutes.slice(0, idx + 1).join("/");
// The title will just be the route string for now
const title = subpath;
return { href, text };
})
// Add in a default "Home" crumb for the top-level
return [{ href: "/", text: "Home" }, ...crumblist];
}
// Call the function to generate the breadcrumbs list
const breadcrumbs = generateBreadcrumbs();
return (
<Breadcrumbs aria-label="breadcrumb" />
);
}
Con esta lista de migas de pan, ahora podemos renderizarlas usando los componentes Breadcrumbs
y . Crumb
Como se mencionó anteriormente, solo return
se muestra la parte de nuestro componente por brevedad.
// ...rest of NextBreadcrumbs component above...
return (
{/* The old breadcrumb ending with '/>' was converted into this */}
<Breadcrumbs aria-label="breadcrumb">
{/*
Iterate through the crumbs, and render each individually.
We "mark" the last crumb to not have a link.
*/}
{breadcrumbs.map((crumb, idx) => (
<Crumb {...crumb} key={idx} last={idx === breadcrumbs.length - 1} />
))}
</Breadcrumbs>
);
Esto debería comenzar a generar algunas migas de pan muy básicas, pero que funcionan, en nuestro sitio una vez renderizado; /user/settings/notifications
representaría como
Home > user > settings > notifications
Sin embargo, hay una mejora rápida que podemos hacer antes de seguir adelante. En este momento, la lista de migas de pan se recrea cada vez que el componente se vuelve a renderizar, por lo que podemos memorizar la lista de migas de una ruta determinada para ahorrar algo de rendimiento. Para lograr esto, podemos envolver nuestra generateBreadcrumbs
llamada de función en el useMemo
gancho React.
const router = useRouter();
// this is the same "generateBreadcrumbs" function, but placed
// inside a "useMemo" call that is dependent on "router.asPath"
const breadcrumbs = React.useMemo(function generateBreadcrumbs() {
const asPathWithoutQuery = router.asPath.split("?")[0];
const asPathNestedRoutes = asPathWithoutQuery.split("/")
.filter(v => v.length > 0);
const crumblist = asPathParts.map((subpath, idx) => {
const href = "/" + asPathNestedRoutes.slice(0, idx + 1).join("/");
return { href, text: subpath };
})
return [{ href: "/", text: "Home" }, ...crumblist];
}, [router.asPath]);
return // ...rest below...
Antes de comenzar a incorporar rutas dinámicas, podemos limpiar más esta solución actual al incluir una forma agradable de cambiar el texto que se muestra para cada migaja generada.
En este momento, si tenemos una ruta como /user/settings/notifications
, se mostrará:
Home > user > settings > notifications
que no es muy atractivo. Podemos proporcionar una función al NextBreadcrumbs
componente que intentará generar un nombre más fácil de usar para cada uno de estos fragmentos de ruta anidados.
const _defaultGetDefaultTextGenerator= path => path
export default function NextBreadcrumbs({ getDefaultTextGenerator=_defaultGetDefaultTextGenerator }) {
const router = useRouter();
// Two things of importance:
// 1. The addition of getDefaultTextGenerator in the useMemo dependency list
// 2. getDefaultTextGenerator is now being used for building the text property
const breadcrumbs = React.useMemo(function generateBreadcrumbs() {
const asPathWithoutQuery = router.asPath.split("?")[0];
const asPathNestedRoutes = asPathWithoutQuery.split("/")
.filter(v => v.length > 0);
const crumblist = asPathParts.map((subpath, idx) => {
const href = "/" + asPathNestedRoutes.slice(0, idx + 1).join("/");
return { href, text: getDefaultTextGenerator(subpath, href) };
})
return [{ href: "/", text: "Home" }, ...crumblist];
}, [router.asPath, getDefaultTextGenerator]);
return ( // ...rest below
y luego nuestro componente principal puede tener algo como lo siguiente, para titular las rutas secundarias, o tal vez incluso reemplazarlas con una nueva cadena.
{/* Assume that `titleize` is written and works appropriately */}
<NextBreadcrumbs getDefaultTextGenerator={path => titleize(path)} />
Esta implementación daría como resultado las siguientes migas de pan. El ejemplo de código completo en la parte inferior tiene más ejemplos de esto.
Home > User > Settings > Notifications
El enrutador de Nextjs permite incluir rutas dinámicas que usan Pattern Matching para permitir que las URL tengan slugs, UUID y otros valores dinámicos que luego se pasarán a sus vistas.
Por ejemplo, si su aplicación Nextjs tiene un componente de página en pages/post/[post_id].js
, las rutas /post/1
y /post/abc
coincidirán con él.
Para nuestro componente de migas de pan, nos gustaría mostrar el nombre de la publicación asociada en lugar de solo su UUID. Esto significa que el componente deberá buscar dinámicamente los datos de la publicación en función de la ruta de la ruta URL anidada y regenerar el texto de la migaja asociada.
En este momento, si visitas /post/abc
, verás migas de pan que parecen
post > abc
pero si la publicación con UUID tiene un título de My First Post
, entonces queremos cambiar las migas de pan para decir
post > My First Post
Profundicemos en cómo puede suceder eso usando async
funciones.
asPath
vspathname
La next/router
instancia del enrutador en nuestro código tiene dos propiedades útiles para nuestro NextBreadcrumbs
componente; asPath
y pathname
. El enrutador asPath
es la ruta URL como se muestra directamente en la barra de URL del navegador. Es pathname
una versión más interna de la URL que tiene las partes dinámicas de la ruta reemplazadas por sus [parameter]
componentes.
Por ejemplo, considere el camino /post/abc
desde arriba.
El asPath
sería /post/abc
como se muestra la URL
El pathname
sería como dicta /post/[post_id]
nuestro directoriopages
Podemos usar estas dos variantes de ruta de URL para crear una forma de obtener información dinámicamente sobre la ruta de navegación, de modo que podamos mostrar información contextualmente más apropiada para el usuario.
Están sucediendo muchas cosas a continuación, así que vuelva a leerlo, y las notas útiles a continuación, unas cuantas veces si es necesario.
const _defaultGetTextGenerator = (param, query) => null;
const _defaultGetDefaultTextGenerator = path => path;
// Pulled out the path part breakdown because its
// going to be used by both `asPath` and `pathname`
const generatePathParts = pathStr => {
const pathWithoutQuery = pathStr.split("?")[0];
return pathWithoutQuery.split("/")
.filter(v => v.length > 0);
}
export default function NextBreadcrumbs({
getTextGenerator=_defaultGetTextGenerator,
getDefaultTextGenerator=_defaultGetDefaultTextGenerator
}) {
const router = useRouter();
const breadcrumbs = React.useMemo(function generateBreadcrumbs() {
const asPathNestedRoutes = generatePathParts(router.asPath);
const pathnameNestedRoutes = generatePathParts(router.pathname);
const crumblist = asPathParts.map((subpath, idx) => {
// Pull out and convert "[post_id]" into "post_id"
const param = pathnameNestedRoutes[idx].replace("[", "").replace("]", "");
const href = "/" + asPathNestedRoutes.slice(0, idx + 1).join("/");
return {
href, textGenerator: getTextGenerator(param, router.query),
text: getDefaultTextGenerator(subpath, href)
};
})
return [{ href: "/", text: "Home" }, ...crumblist];
}, [router.asPath, router.pathname, router.query, getTextGenerator, getDefaultTextGenerator]);
return ( // ...rest below
El asPath
desglose se movió a una generatePathParts
función ya que se usa la misma lógica para ambos router.asPath
y router.pathname
.
Determine el param
éter que se alinea con el valor de la ruta dinámica, por lo que abc
daría como resultado post_id
.
El éter de ruta anidado param
y todos los valores de consulta asociados ( router.query
) se pasan a un proveedor getTextGenerator
que devolverá un null
valor o una Promise
respuesta que debería devolver la cadena dinámica para usar en la ruta de navegación asociada.
La useMemo
matriz de dependencia tiene más dependencias agregadas; router.pathname
, router.query
y getTextGenerator
.
Finalmente, necesitamos actualizar el Crumb
componente para usar este textGenerator
valor si se proporciona para el objeto migas asociado.
function Crumb({ text: defaultText, textGenerator, href, last=false }) {
const [text, setText] = React.useState(defaultText);
useEffect(async () => {
// If `textGenerator` is nonexistent, then don't do anything
if (!Boolean(textGenerator)) { return; }
// Run the text generator and set the text again
const finalText = await textGenerator();
setText(finalText);
}, [textGenerator]);
if (last) {
return <Typography color="text.primary">{text}</Typography>
}
return (
<Link underline="hover" color="inherit" href={href}>
{text}
</Link>
);
}
Las migas de pan ahora pueden manejar rutas estáticas y rutas dinámicas de forma limpia con el potencial de mostrar valores fáciles de usar. Si bien el código anterior es la lógica comercial del componente, todo esto se puede usar con un componente principal que se parece al ejemplo final a continuación.
// NextBreadcrumbs.js
const _defaultGetTextGenerator = (param, query) => null;
const _defaultGetDefaultTextGenerator = path => path;
// Pulled out the path part breakdown because its
// going to be used by both `asPath` and `pathname`
const generatePathParts = pathStr => {
const pathWithoutQuery = pathStr.split("?")[0];
return pathWithoutQuery.split("/")
.filter(v => v.length > 0);
}
export default function NextBreadcrumbs({
getTextGenerator=_defaultGetTextGenerator,
getDefaultTextGenerator=_defaultGetDefaultTextGenerator
}) {
const router = useRouter();
const breadcrumbs = React.useMemo(function generateBreadcrumbs() {
const asPathNestedRoutes = generatePathParts(router.asPath);
const pathnameNestedRoutes = generatePathParts(router.pathname);
const crumblist = asPathParts.map((subpath, idx) => {
// Pull out and convert "[post_id]" into "post_id"
const param = pathnameNestedRoutes[idx].replace("[", "").replace("]", "");
const href = "/" + asPathNestedRoutes.slice(0, idx + 1).join("/");
return {
href, textGenerator: getTextGenerator(param, router.query),
text: getDefaultTextGenerator(subpath, href)
};
})
return [{ href: "/", text: "Home" }, ...crumblist];
}, [router.asPath, router.pathname, router.query, getTextGenerator, getDefaultTextGenerator]);
return (
<Breadcrumbs aria-label="breadcrumb">
{breadcrumbs.map((crumb, idx) => (
<Crumb {...crumb} key={idx} last={idx === breadcrumbs.length - 1} />
))}
</Breadcrumbs>
);
}
function Crumb({ text: defaultText, textGenerator, href, last=false }) {
const [text, setText] = React.useState(defaultText);
useEffect(async () => {
// If `textGenerator` is nonexistent, then don't do anything
if (!Boolean(textGenerator)) { return; }
// Run the text generator and set the text again
const finalText = await textGenerator();
setText(finalText);
}, [textGenerator]);
if (last) {
return <Typography color="text.primary">{text}</Typography>
}
return (
<Link underline="hover" color="inherit" href={href}>
{text}
</Link>
);
}
y luego se puede ver un ejemplo de este NextBreadcrumbs
uso a continuación. Tenga en cuenta que useCallback
se utiliza para crear solo una referencia a cada función auxiliar, lo que evitará que se vuelvan a procesar innecesariamente las migas de pan cuando/si el componente de diseño de página se vuelve a procesar. También puede mover esto al alcance de nivel superior del archivo, pero no me gusta contaminar el alcance global de esa manera.
// MyPage.js (Parent Component)
import React from 'react';
import NextBreadcrumbs from "./NextBreadcrumbs";
function MyPageLayout() {
// Either lookup a nice label for the subpath, or just titleize it
const getDefaultTextGenerator = React.useCallback((subpath) => {
return {
"post": "Posts",
"settings": "User Settings",
}[subpath] || titleize(subpath);
}, [])
// Assuming `fetchAPI` loads data from the API and this will use the
// parameter name to determine how to resolve the text. In the example,
// we fetch the post from the API and return it's `title` property
const getTextGenerator = React.useCallback((param, query) => {
return {
"post_id": () => await fetchAPI(`/posts/${query.post_id}/`).title,
}[param];
}, []);
return () {
<div>
{/* ...Whatever else... */}
<NextBreadcrumbs
getDefaultTextGenerator={getDefaultTextGenerator}
getTextGenerator={getTextGenerator}
/>
{/* ...Whatever else... */}
</div>
}
}
Esta es una de mis publicaciones más profundas y técnicas, así que espero que la hayas disfrutado, y por favor comenta o comunícate para que pueda garantizar la coherencia y la corrección. Con suerte, esta publicación le enseñó algunas estrategias o conceptos sobre Nextjs.
Fuente: https://hackernoon.com/implement-a-dynamic-breadcrumb-in-reactnextjs
1615544450
Since March 2020 reached 556 million monthly downloads have increased, It shows that React JS has been steadily growing. React.js also provides a desirable amount of pliancy and efficiency for developing innovative solutions with interactive user interfaces. It’s no surprise that an increasing number of businesses are adopting this technology. How do you select and recruit React.js developers who will propel your project forward? How much does a React developer make? We’ll bring you here all the details you need.
Facebook built and maintains React.js, an open-source JavaScript library for designing development tools. React.js is used to create single-page applications (SPAs) that can be used in conjunction with React Native to develop native cross-platform apps.
In the United States, the average React developer salary is $94,205 a year, or $30-$48 per hour, This is one of the highest among JavaScript developers. The starting salary for junior React.js developers is $60,510 per year, rising to $112,480 for senior roles.
In context of software developer wage rates, the United States continues to lead. In high-tech cities like San Francisco and New York, average React developer salaries will hit $98K and $114per year, overall.
However, the need for React.js and React Native developer is outpacing local labour markets. As a result, many businesses have difficulty locating and recruiting them locally.
It’s no surprise that for US and European companies looking for professional and budget engineers, offshore regions like India are becoming especially interesting. This area has a large number of app development companies, a good rate with quality, and a good pool of React.js front-end developers.
As per Linkedin, the country’s IT industry employs over a million React specialists. Furthermore, for the same or less money than hiring a React.js programmer locally, you may recruit someone with much expertise and a broader technical stack.
React is a very strong framework. React.js makes use of a powerful synchronization method known as Virtual DOM, which compares the current page architecture to the expected page architecture and updates the appropriate components as long as the user input.
React is scalable. it utilises a single language, For server-client side, and mobile platform.
React is steady.React.js is completely adaptable, which means it seldom, if ever, updates the user interface. This enables legacy projects to be updated to the most new edition of React.js without having to change the codebase or make a few small changes.
React is adaptable. It can be conveniently paired with various state administrators (e.g., Redux, Flux, Alt or Reflux) and can be used to implement a number of architectural patterns.
Is there a market for React.js programmers?
The need for React.js developers is rising at an unparalleled rate. React.js is currently used by over one million websites around the world. React is used by Fortune 400+ businesses and popular companies such as Facebook, Twitter, Glassdoor and Cloudflare.
As you’ve seen, locating and Hire React js Developer and Hire React Native developer is a difficult challenge. You will have less challenges selecting the correct fit for your projects if you identify growing offshore locations (e.g. India) and take into consideration the details above.
If you want to make this process easier, You can visit our website for more, or else to write a email, we’ll help you to finding top rated React.js and React Native developers easier and with strives to create this operation
#hire-react-js-developer #hire-react-native-developer #react #react-native #react-js #hire-react-js-programmer