Saltar al contenido principal
Lab abierto

Experimentos y
aprendizaje continuo

Experimentos de código, snippets reutilizables y lo que voy aprendiendo en el día a día del desarrollo web.

Aprendizajes diarios
Snippets de código

Lo que aprendí hoy

Aprendizajes breves y gotchas del día a día en desarrollo.

Next.js revalidateTag() requiere expire: 0 para invalidación inmediata

Descubrí que `revalidateTag(tag)` en Next.js 16 no invalida inmediatamente por defecto. Debes usar `revalidateTag(tag, { expire: 0 })` para forzar invalidación inmediata del cache. Esto es crítico para webhooks de revalidación.

Next.jsCachePerformance
Docs

OKLCH es superior a HSL para dark mode

OKLCH (color space) mantiene la percepción de brillo constante entre colores, a diferencia de HSL. Esto significa que un color con L=0.5 en OKLCH se verá igualmente brillante que cualquier otro color con L=0.5, perfecto para sistemas de diseño consistentes en dark mode.

CSSDesignAccessibility

React.cache() deduplica requests en Server Components

Usar `cache()` de React en funciones de data fetching en Next.js App Router deduplica automáticamente requests idénticos durante el mismo render tree. Ejemplo: si 3 componentes llaman a `getPost('slug-123')`, solo se ejecuta 1 request.

ReactNext.jsPerformance

Tailwind @theme inline reemplaza extend en v4

En Tailwind CSS v4, `@theme inline` es la nueva forma de extender el tema. Reemplaza `theme: { extend: {} }` de v3. Mejora el performance del build y permite CSS variables nativas.

TailwindCSS

AVIF reduce imágenes 50% vs WebP

AVIF logra la misma calidad visual que WebP con ~50% menos tamaño de archivo. Para Next.js, configurar `formats: ['image/avif', 'image/webp']` como fallback es la mejor estrategia. Safari soporta AVIF desde v16.4.

PerformanceImagesNext.js

adjustFontFallback previene CLS en font loading

Next.js tiene `adjustFontFallback: true` en next/font que ajusta métricas del fallback font para match el custom font, previniendo layout shift (CLS) durante la carga. Crítico para Core Web Vitals.

Next.jsPerformanceFonts

No te pierdas ningún aprendizaje

Suscríbete al feed RSS y recibe cada nuevo TIL y artículo directamente en tu lector favorito.

Suscribirme al RSS

Biblioteca de snippets

Snippets útiles y patrones probados en producción.

Laravel Action Pattern

Patrón Action moderno para encapsular lógica de negocio en Laravel 12 con PHP 8.2+.

phpLaravelPHPDesign Patterns
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Data\CreatePostData;
use App\Models\Post;
use Illuminate\Support\Facades\DB;

final readonly class CreatePostAction
{
    public function __construct(
        private SyncTagsAction $syncTags,
    ) {}

    public function __invoke(CreatePostData $data): Post
    {
        return DB::transaction(function () use ($data): Post {
            $post = Post::query()->create([
                'title' => $data->title,
                'slug' => str($data->title)->slug()->toString(),
                'body' => $data->body,
                'status' => $data->status,
                'published_at' => $data->publishAt,
                'user_id' => $data->userId,
            ]);

            if ($data->tags !== null) {
                ($this->syncTags)($post, $data->tags);
            }

            return $post->refresh();
        });
    }
}

// DTO tipado con constructor promotion
final readonly class CreatePostData
{
    /**
     * @param list<string>|null $tags
     */
    public function __construct(
        public string $title,
        public string $body,
        public string $status = 'draft',
        public ?CarbonImmutable $publishAt = null,
        public int $userId,
        public ?array $tags = null,
    ) {}
}

// Uso en un controller:
// public function store(StorePostRequest $request, CreatePostAction $action): JsonResponse
// {
//     $post = $action(CreatePostData::from($request->validated()));
//     return response()->json($post, 201);
// }

React useLocalStorage Hook

Custom hook para sincronizar state con localStorage automáticamente.

typescriptReactTypeScriptHooks
import { useState, useEffect } from 'react';

function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    if (typeof window === 'undefined') {
      return initialValue;
    }
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(`Error loading localStorage key "${key}":`, error);
      return initialValue;
    }
  });

  const setValue = (value: T | ((val: T) => T)) => {
    try {
      const valueToStore =
        value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      if (typeof window !== 'undefined') {
        window.localStorage.setItem(key, JSON.stringify(valueToStore));
      }
    } catch (error) {
      console.error(`Error setting localStorage key "${key}":`, error);
    }
  };

  return [storedValue, setValue] as const;
}

export default useLocalStorage;

Next.js ISR Webhook Revalidation

API route para revalidar cache de Next.js ISR via webhook desde CMS.

typescriptNext.jsAPICache
import { NextRequest, NextResponse } from 'next/server';
import { revalidateTag, revalidatePath } from 'next/cache';

export async function POST(request: NextRequest) {
  const authHeader = request.headers.get('authorization');
  const token = authHeader?.replace('Bearer ', '');

  if (token !== process.env.REVALIDATE_TOKEN) {
    return NextResponse.json(
      { error: 'Unauthorized' },
      { status: 401 }
    );
  }

  try {
    const body = await request.json();
    const { tags, paths } = body;

    // Revalidate tags first (fetch cache)
    if (tags?.length) {
      for (const tag of tags) {
        revalidateTag(tag, { expire: 0 }); // Immediate invalidation
      }
    }

    // Then revalidate paths (ISR pages)
    if (paths?.length) {
      for (const path of paths) {
        revalidatePath(path);
      }
    }

    return NextResponse.json({
      revalidated: true,
      tags,
      paths,
      now: Date.now(),
    });
  } catch (err) {
    return NextResponse.json(
      { error: 'Error revalidating' },
      { status: 500 }
    );
  }
}

Tailwind Responsive Font Sizes

Sistema de tipografía fluida con clamp() para responsive design.

cssTailwindCSSTypography
/* app/globals.css */

.text-hero {
  font-size: clamp(3rem, 8vw, 8rem);
  line-height: 1.1;
  letter-spacing: -0.02em;
}

.text-display {
  font-size: clamp(2.5rem, 6vw, 6rem);
  line-height: 1.15;
  letter-spacing: -0.015em;
}

.text-heading-xl {
  font-size: clamp(2rem, 4vw, 4rem);
  line-height: 1.2;
}

.text-heading-lg {
  font-size: clamp(1.75rem, 3vw, 3rem);
  line-height: 1.25;
}

.text-heading-md {
  font-size: clamp(1.5rem, 2.5vw, 2.5rem);
  line-height: 1.3;
}

.text-body-lg {
  font-size: clamp(1.125rem, 1.5vw, 1.25rem);
  line-height: 1.75;
}

React useDebounce Hook

Hook para debounce de valores, útil para búsquedas y API calls.

typescriptReactTypeScriptPerformance
import { useState, useEffect } from 'react';

function useDebounce<T>(value: T, delay: number = 500): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

  return debouncedValue;
}

export default useDebounce;

// Usage example:
// const [searchTerm, setSearchTerm] = useState('');
// const debouncedSearch = useDebounce(searchTerm, 500);
//
// useEffect(() => {
//   if (debouncedSearch) {
//     // Perform search API call
//     fetchResults(debouncedSearch);
//   }
// }, [debouncedSearch]);

Laravel Query Scopes Modernos

Query scopes reutilizables con el atributo #[Scope] de Laravel 12 y PHP 8.2+.

phpLaravelDatabaseEloquent
<?php

declare(strict_types=1);

namespace App\Models;

use App\Models\Scopes\PublishedScope;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

#[ScopedBy(PublishedScope::class)]
class Post extends Model
{
    /**
     * Scope: Filter by category slug.
     *
     * @param Builder<Post> $query
     */
    #[Scope]
    protected function category(Builder $query, string $category): void
    {
        $query->whereHas(
            'categories',
            fn (Builder $q) => $q->where('slug', $category),
        );
    }

    /**
     * Scope: Full-text search in title and content.
     *
     * @param Builder<Post> $query
     */
    #[Scope]
    protected function search(Builder $query, ?string $term): void
    {
        if ($term === null || $term === '') {
            return;
        }

        $query->where(
            fn (Builder $q) => $q
                ->where('title', 'LIKE', "%{$term}%")
                ->orWhere('content', 'LIKE', "%{$term}%"),
        );
    }

    /**
     * Scope: Order by popularity (views + recency).
     *
     * @param Builder<Post> $query
     */
    #[Scope]
    protected function popular(Builder $query): void
    {
        $query->orderByDesc('views_count')
              ->orderByDesc('created_at');
    }
}

// Global scope como clase (registrado via #[ScopedBy]):
// class PublishedScope implements Scope
// {
//     public function apply(Builder $query, Model $model): void
//     {
//         $query->where('status', 'published')
//               ->whereNotNull('published_at')
//               ->where('published_at', '<=', now());
//     }
// }

// Uso:
// Post::query()->category('laravel')->search('cache')->popular()->get();
// Post::query()->withoutGlobalScope(PublishedScope::class)->get(); // incluir drafts

¿Tienes un snippet que te haya salvado?

Comparte tus descubrimientos y ayuda a otros a resolver los mismos problemas.

Contribuir en GitHub