Cómo crear una tabla dinámica en R (como en Excel)

Manipulación de datos con R Transformación de datos
Tablas dinámicas de Excel en R con xtabs y dplyr

Una tabla dinámica (PivotTable en la versión en inglés de Excel) resume un conjunto de datos colocando una variable en las filas, otra en las columnas y una suma, un recuento o un promedio en las celdas. R no tiene un botón de “Insertar tabla dinámica”, pero tampoco lo necesita: con unas pocas líneas de código usando table, xtabs, aggregate, tapply o dplyr y tidyr obtienes el mismo resultado, y puedes volver a ejecutar el código cada vez que cambien los datos. En este tutorial aprenderás a qué equivale en R cada área de la tabla dinámica de Excel, cómo añadir totales generales, subtotales y porcentajes, cómo filtrar y ordenar la tabla y cómo exportarla de nuevo a Excel.

Respuesta rápida

La forma más flexible de crear una tabla dinámica en R es resumir por grupos y después convertir una de las variables de agrupación en columnas. El siguiente código calcula los ingresos totales de cada región (filas) y producto (columnas), rellenando las celdas vacías con 0:

library(dplyr)
library(tidyr)

ventas %>%
  group_by(region, producto) %>%
  summarise(ingresos = sum(ingresos), .groups = "drop") %>%
  pivot_wider(names_from = producto, values_from = ingresos, values_fill = 0)
# A tibble: 3 × 4
  region Móvil Portátil Tablet
  <chr>  <dbl>    <dbl>  <dbl>
1 Este   10000     8000   1200
2 Oeste  12500     4000   1800
3 Sur        0     8000   2100

Si solo necesitas recuentos o sumas de una variable, en R base es todavía más corto: xtabs(ingresos ~ region + producto, data = ventas) devuelve la misma tabla. Ambas opciones se explican a continuación paso a paso.

Datos de ejemplo

A lo largo del tutorial usaremos un pequeño data frame con las ventas de una tienda. Cada fila es una venta, igual que estaría en una hoja de Excel antes de insertar la tabla dinámica: la región, el producto, el mes, las unidades vendidas y los ingresos.

ventas <- data.frame(
  region   = c("Este", "Este", "Este", "Este", "Oeste", "Oeste",
               "Oeste", "Oeste", "Sur", "Sur", "Sur", "Este"),
  producto = c("Portátil", "Móvil", "Portátil", "Móvil", "Portátil", "Móvil",
               "Tablet", "Móvil", "Portátil", "Tablet", "Portátil", "Tablet"),
  mes      = c("Ene", "Ene", "Feb", "Feb", "Ene", "Ene",
               "Feb", "Mar", "Ene", "Feb", "Mar", "Mar"),
  unidades = c(5, 12, 3, 8, 4, 15, 6, 10, 2, 7, 6, 4),
  ingresos = c(5000, 6000, 3000, 4000, 4000, 7500, 1800, 5000, 2000, 2100, 6000, 1200)
)

ventas
   region producto mes unidades ingresos
1    Este Portátil Ene        5     5000
2    Este    Móvil Ene       12     6000
3    Este Portátil Feb        3     3000
4    Este    Móvil Feb        8     4000
5   Oeste Portátil Ene        4     4000
6   Oeste    Móvil Ene       15     7500
7   Oeste   Tablet Feb        6     1800
8   Oeste    Móvil Mar       10     5000
9     Sur Portátil Ene        2     2000
10    Sur   Tablet Feb        7     2100
11    Sur Portátil Mar        6     6000
12   Este   Tablet Mar        4     1200

Fíjate en que la región Sur nunca vendió un móvil. Esa combinación vacía es la que en Excel verás como una celda en blanco y en R como NA o 0, según la función que uses.

Si tus datos están en un archivo de Excel, léelos primero con read_excel y el resto del tutorial funciona igual.

De las áreas de la tabla dinámica de Excel a R

En Excel arrastras los campos a cuatro áreas: Filas (Rows), Columnas (Columns), Valores (Values) y Filtros (Filters). Cada una tiene un equivalente directo en R:

Área de la tabla dinámica Qué hace R base dplyr + tidyr
Filas Grupos que se muestran como filas Lado izquierdo de ~ o primer factor group_by(region)
Columnas Grupos que se muestran como columnas Segundo factor pivot_wider(names_from = producto)
Valores (Suma, Cuenta, Promedio) Resumen que aparece en las celdas FUN = sum, table, xtabs(y ~ ...) summarise(ingresos = sum(ingresos))
Filtros Limita las filas que se usan Argumento subset o ventas[cond, ] filter(mes == "Ene")
Totales generales Totales de filas y columnas addmargins bind_rows con una fila de totales
Mostrar valores como (% de…) Porcentajes prop.table mutate(pct = x / sum(x))

Ten esta tabla en mente: cualquier tabla dinámica en R no es más que una combinación de estas piezas.

Contar filas: table y xtabs

La tabla dinámica más sencilla es la que cuenta cuántas filas hay en cada combinación, como cuando pones “Cuenta de” en el área de Valores. En R esto es una tabla de contingencia y puedes crearla con la función table:

table(ventas$region, ventas$producto)
        Móvil Portátil Tablet
  Este      2        2      1
  Oeste     2        1      1
  Sur       0        2      1

Lee la salida como “la región Este tiene 2 ventas de móviles, 2 de portátiles y 1 de tablets”. Son número de filas (pedidos), no unidades vendidas.

La función xtabs hace lo mismo con una fórmula y el argumento data, lo que resulta más limpio y además conserva los nombres de las variables como encabezados:

xtabs(~ region + producto, data = ventas)
       producto
region  Móvil Portátil Tablet
  Este      2        2      1
  Oeste     2        1      1
  Sur       0        2      1

La variable que va antes del + se coloca en las filas y la que va después en las columnas. Aprende a trabajar con estos objetos en el artículo sobre tablas de contingencia en R.

Sumar y promediar: xtabs, aggregate y tapply

Sumar con xtabs

Si pones una variable numérica en el lado izquierdo de la fórmula, xtabs la suma en lugar de contar filas. Es la tabla dinámica de “Suma de ingresos”:

xtabs(ingresos ~ region + producto, data = ventas)
       producto
region  Móvil Portátil Tablet
  Este  10000     8000   1200
  Oeste 12500     4000   1800
  Sur       0     8000   2100

xtabs solo sabe sumar. Para medias, medianas o cualquier otra función necesitas aggregate, tapply o dplyr.

aggregate

La función aggregate aplica cualquier función por grupos, pero devuelve una tabla larga, con una fila por combinación, en lugar de una tabla cruzada:

aggregate(ingresos ~ region + producto, data = ventas, FUN = sum)
  region producto ingresos
1   Este    Móvil    10000
2  Oeste    Móvil    12500
3   Este Portátil     8000
4  Oeste Portátil     4000
5    Sur Portátil     8000
6   Este   Tablet     1200
7  Oeste   Tablet     1800
8    Sur   Tablet     2100

Este formato largo equivale a la tabla dinámica de Excel con los dos campos en el área de Filas (“Mostrar en formato tabular”). Observa que las combinaciones sin datos, como Sur y Móvil, directamente no aparecen. Con una sola variable de agrupación, aggregate es la forma más rápida de obtener la media de cada grupo:

aggregate(ingresos ~ region, data = ventas, FUN = mean)
  region ingresos
1   Este 3840.000
2  Oeste 4575.000
3    Sur 3366.667

tapply

La función tapply devuelve directamente una tabla cruzada cuando le pasas una lista con dos variables de agrupación. Aquí calculamos las unidades medias por venta de cada región y producto:

tapply(ventas$unidades, list(ventas$region, ventas$producto), mean)
      Móvil Portátil Tablet
Este   10.0        4      4
Oeste  12.5        4      6
Sur      NA        4      7

La combinación vacía aparece ahora como NA, porque no hay ningún valor que promediar. Cuando sumas con tapply, puedes sustituir esos NA por ceros con el argumento default:

tapply(ventas$ingresos, list(ventas$region, ventas$producto), sum, default = 0)
      Móvil Portátil Tablet
Este  10000     8000   1200
Oeste 12500     4000   1800
Sur       0     8000   2100

Con dplyr: group_by, summarise y pivot_wider

El enfoque del tidyverse tiene dos pasos y es el que mejor escala cuando la tabla dinámica se complica:

  1. Agrupa con group_by por los campos de Filas y Columnas y resume con summarise el campo de Valores. Obtienes una tabla larga.
  2. Convierte el campo de Columnas en columnas con pivot_wider.

El primer paso devuelve la misma tabla larga que aggregate:

library(dplyr)
library(tidyr)

larga <- ventas %>%
  group_by(region, producto) %>%
  summarise(ingresos = sum(ingresos), .groups = "drop")

larga
# A tibble: 8 × 3
  region producto ingresos
  <chr>  <chr>       <dbl>
1 Este   Móvil       10000
2 Este   Portátil     8000
3 Este   Tablet       1200
4 Oeste  Móvil       12500
5 Oeste  Portátil     4000
6 Oeste  Tablet       1800
7 Sur    Portátil     8000
8 Sur    Tablet       2100

Y el segundo paso convierte los productos en columnas:

larga %>%
  pivot_wider(names_from = producto, values_from = ingresos)
# A tibble: 3 × 4
  region Móvil Portátil Tablet
  <chr>  <dbl>    <dbl>  <dbl>
1 Este   10000     8000   1200
2 Oeste  12500     4000   1800
3 Sur       NA     8000   2100

El resultado es un data frame (un tibble), no un objeto de tipo tabla, así que puedes seguir trabajando con él usando mutate, filter o arrange y exportarlo tal cual.

Para mostrar una media o un recuento en lugar de una suma, cambia la función dentro de summarise: mean(unidades), n(), median(ingresos), max(ingresos), etc. Por ejemplo, las unidades medias por venta según región y mes:

ventas %>%
  group_by(region, mes) %>%
  summarise(media_unidades = mean(unidades), .groups = "drop") %>%
  pivot_wider(names_from = mes, values_from = media_unidades)
# A tibble: 3 × 4
  region   Ene   Feb   Mar
  <chr>  <dbl> <dbl> <dbl>
1 Este     8.5   5.5     4
2 Oeste    9.5   6      10
3 Sur      2     7       6

Rellenar las celdas vacías con 0

En la tabla anterior de ingresos la celda Sur-Móvil era NA. Cuando el valor es una suma o un recuento, una celda vacía significa realmente cero, así que indica values_fill = 0 en pivot_wider, que equivale a la opción de Excel “Para celdas vacías, mostrar: 0”:

dinamica <- ventas %>%
  group_by(region, producto) %>%
  summarise(ingresos = sum(ingresos), .groups = "drop") %>%
  pivot_wider(names_from = producto, values_from = ingresos,
              values_fill = 0)

dinamica
# A tibble: 3 × 4
  region Móvil Portátil Tablet
  <chr>  <dbl>    <dbl>  <dbl>
1 Este   10000     8000   1200
2 Oeste  12500     4000   1800
3 Sur        0     8000   2100

No rellenes con 0 una tabla dinámica de medias. La media de un producto que nunca se vendió no es 0, simplemente no existe. Un 0 ahí rebajaría cualquier promedio que calcules después a partir de la tabla. En ese caso, deja el NA.

Varios campos de valores

En Excel puedes arrastrar varios campos al área de Valores, por ejemplo “Suma de unidades” y “Suma de ingresos”. En dplyr basta con añadir más columnas dentro de summarise. Si la tabla no tiene campo de Columnas, ya has terminado:

ventas %>%
  group_by(region) %>%
  summarise(pedidos      = n(),
            unidades     = sum(unidades),
            ingresos     = sum(ingresos),
            precio_medio = sum(ingresos) / sum(unidades))
# A tibble: 3 × 5
  region pedidos unidades ingresos precio_medio
  <chr>    <int>    <dbl>    <dbl>        <dbl>
1 Este         5       32    19200         600 
2 Oeste        4       35    18300         523.
3 Sur          3       15    10100         673.

Si además necesitas un campo de Columnas, pasa todas las columnas de valores a values_from. pivot_wider construirá los nombres uniendo el valor y la categoría:

ventas %>%
  group_by(region, producto) %>%
  summarise(unidades = sum(unidades), ingresos = sum(ingresos), .groups = "drop") %>%
  pivot_wider(names_from = producto,
              values_from = c(unidades, ingresos),
              values_fill = 0)
# A tibble: 3 × 7
  region unidades_Móvil unidades_Portátil unidades_Tablet ingresos_Móvil
  <chr>           <dbl>             <dbl>           <dbl>          <dbl>
1 Este               20                 8               4          10000
2 Oeste              25                 4               6          12500
3 Sur                 0                 8               7              0
# ℹ 2 more variables: ingresos_Portátil <dbl>, ingresos_Tablet <dbl>

Las últimas columnas no se muestran porque no caben en el ancho de la consola, pero están en el data frame. Usa print(width = Inf) o View() para verlas todas.

Totales generales y subtotales

addmargins

Si tu tabla dinámica es un objeto de tipo tabla (creado con table, xtabs o tapply), addmargins añade los totales de filas y columnas en una sola línea, con la etiqueta Sum:

tab <- xtabs(ingresos ~ region + producto, data = ventas)

addmargins(tab)
       producto
region  Móvil Portátil Tablet   Sum
  Este  10000     8000   1200 19200
  Oeste 12500     4000   1800 18300
  Sur       0     8000   2100 10100
  Sum   22500    20000   5100 47600

Usa margin = 1 para añadir solo la fila inferior de totales o margin = 2 para añadir solo la columna de totales. En la sección de addmargins del artículo sobre table tienes más ejemplos.

Totales en R base con rowSums y colSums

Sin addmargins (por ejemplo, si quieres llamar “Total” a los totales o estás trabajando con una matriz), añade tú mismo las sumas de filas y columnas:

pv <- tapply(ventas$ingresos, list(ventas$region, ventas$producto), sum, default = 0)

pv <- cbind(pv, Total = rowSums(pv))
pv <- rbind(pv, Total = colSums(pv))

pv
      Móvil Portátil Tablet Total
Este  10000     8000   1200 19200
Oeste 12500     4000   1800 18300
Sur       0     8000   2100 10100
Total 22500    20000   5100 47600

El orden importa: primero se añade la columna de totales, así que colSums también la suma y la celda inferior derecha es el total general.

Totales en dplyr con bind_rows

Con un data frame, añade la columna de totales con rowSums dentro de mutate y la fila de totales sumando todas las columnas numéricas y uniéndola al final con bind_rows:

dinamica_tot <- dinamica %>%
  mutate(Total = rowSums(across(where(is.numeric))))

totales <- dinamica_tot %>%
  summarise(across(where(is.numeric), sum)) %>%
  mutate(region = "Total")

bind_rows(dinamica_tot, totales)
# A tibble: 4 × 5
  region Móvil Portátil Tablet Total
  <chr>  <dbl>    <dbl>  <dbl> <dbl>
1 Este   10000     8000   1200 19200
2 Oeste  12500     4000   1800 18300
3 Sur        0     8000   2100 10100
4 Total  22500    20000   5100 47600

bind_rows une las columnas por su nombre, así que no importa el orden de las columnas en totales.

Subtotales

Excel muestra un subtotal de cada grupo cuando pones dos campos en Filas. En R, calcula el detalle y los subtotales por separado, apílalos y ordénalos para que cada subtotal quede después de su grupo:

detalle <- ventas %>%
  group_by(region, producto) %>%
  summarise(ingresos = sum(ingresos), .groups = "drop")

subtotales <- detalle %>%
  group_by(region) %>%
  summarise(ingresos = sum(ingresos)) %>%
  mutate(producto = "Subtotal")

bind_rows(detalle, subtotales) %>%
  arrange(region, producto == "Subtotal")
# A tibble: 11 × 3
   region producto ingresos
   <chr>  <chr>       <dbl>
 1 Este   Móvil       10000
 2 Este   Portátil     8000
 3 Este   Tablet       1200
 4 Este   Subtotal    19200
 5 Oeste  Móvil       12500
 6 Oeste  Portátil     4000
 7 Oeste  Tablet       1800
 8 Oeste  Subtotal    18300
 9 Sur    Portátil     8000
10 Sur    Tablet       2100
11 Sur    Subtotal    10100

El truco está en arrange(region, producto == "Subtotal"): la comparación vale FALSE para los productos y TRUE para el subtotal, y como FALSE se ordena antes que TRUE, la fila del subtotal siempre queda la última dentro de su región.

Porcentaje del total de fila, de columna y general

El menú “Mostrar valores como” (Show Values As) de Excel permite ver el “% del total general”, el “% del total de fila” o el “% del total de columna”. En R, los tres se calculan con prop.table y su argumento margin (consulta prop.table):

# % del total general
round(prop.table(tab) * 100, 1)
       producto
region  Móvil Portátil Tablet
  Este   21.0     16.8    2.5
  Oeste  26.3      8.4    3.8
  Sur     0.0     16.8    4.4
# % del total de fila (cada región suma 100)
round(prop.table(tab, margin = 1) * 100, 1)
       producto
region  Móvil Portátil Tablet
  Este   52.1     41.7    6.2
  Oeste  68.3     21.9    9.8
  Sur     0.0     79.2   20.8
# % del total de columna (cada producto suma 100)
round(prop.table(tab, margin = 2) * 100, 1)
       producto
region  Móvil Portátil Tablet
  Este   44.4     40.0   23.5
  Oeste  55.6     20.0   35.3
  Sur     0.0     40.0   41.2

Cada tabla responde a una pregunta distinta. Los porcentajes por fila dicen que el 79,2% de los ingresos del Sur viene de los portátiles, mientras que los porcentajes por columna dicen que el Sur genera el 40% de todos los ingresos de portátiles. Confundirlos es el error más habitual al leer este tipo de tablas, así que comprueba qué margen suma 100 antes de sacar conclusiones. Si envuelves el resultado en addmargins(..., margin = 2) se añade una columna de 100 que lo deja claro.

Con dplyr, calcula el porcentaje dentro de cada región con mutate y .by antes de ensanchar la tabla:

ventas %>%
  group_by(region, producto) %>%
  summarise(ingresos = sum(ingresos), .groups = "drop") %>%
  mutate(pct = round(ingresos / sum(ingresos) * 100, 1), .by = region) %>%
  select(-ingresos) %>%
  pivot_wider(names_from = producto, values_from = pct, values_fill = 0)
# A tibble: 3 × 4
  region Móvil Portátil Tablet
  <chr>  <dbl>    <dbl>  <dbl>
1 Este    52.1     41.7    6.2
2 Oeste   68.3     21.9    9.8
3 Sur      0       79.2   20.8

Quita .by = region para obtener el porcentaje sobre el total general o usa .by = producto para el porcentaje de cada columna.

Filtros (el filtro del informe)

El área de Filtros de una tabla dinámica limita las filas que se usan para construirla. En R simplemente filtras los datos antes de crear la tabla. Por ejemplo, los ingresos de enero y febrero:

ventas %>%
  filter(mes %in% c("Ene", "Feb")) %>%
  group_by(region, producto) %>%
  summarise(ingresos = sum(ingresos), .groups = "drop") %>%
  pivot_wider(names_from = producto, values_from = ingresos, values_fill = 0)
# A tibble: 3 × 4
  region Móvil Portátil Tablet
  <chr>  <dbl>    <dbl>  <dbl>
1 Este   10000     8000      0
2 Oeste   7500     4000   1800
3 Sur        0     2000   2100

Consulta la función filter para ver todas las formas de escribir condiciones. En R base, xtabs y aggregate tienen un argumento subset que hace lo mismo:

xtabs(ingresos ~ region + producto, data = ventas, subset = mes == "Ene")
       producto
region  Móvil Portátil
  Este   6000     5000
  Oeste  7500     4000
  Sur       0     2000

Observa que la columna Tablet ha desaparecido, porque en enero no se vendió ninguna. A diferencia de Excel, R solo crea columnas para los valores que aparecen en los datos filtrados. Si necesitas un conjunto fijo de columnas, convierte la variable en un factor con todos sus niveles antes de filtrar.

Ordenar la tabla dinámica

Para ordenar las filas según un valor, como “Ordenar de mayor a menor” en Excel, usa arrange sobre la tabla. Aquí ordenamos las regiones por sus ingresos totales:

dinamica_tot %>%
  arrange(desc(Total))
# A tibble: 3 × 5
  region Móvil Portátil Tablet Total
  <chr>  <dbl>    <dbl>  <dbl> <dbl>
1 Este   10000     8000   1200 19200
2 Oeste  12500     4000   1800 18300
3 Sur        0     8000   2100 10100

El orden de las columnas es otra historia. table y xtabs las ordenan alfabéticamente, y lo mismo ocurre con dplyr: group_by ordena los grupos alfabéticamente y pivot_wider crea las columnas en el orden en el que aparecen en ese resumen. Con nuestros meses (“Ene”, “Feb”, “Mar”) coincide con el orden correcto por casualidad, pero con “Abr”, “Ago” o “Dic” no sería así. Convierte la variable de las columnas en un factor con los niveles en el orden que quieras y tanto pivot_wider como table lo respetarán. Por ejemplo, para mostrar primero las tablets:

ventas %>%
  mutate(producto = factor(producto, levels = c("Tablet", "Móvil", "Portátil"))) %>%
  group_by(region, producto) %>%
  summarise(unidades = sum(unidades), .groups = "drop") %>%
  pivot_wider(names_from = producto, values_from = unidades, values_fill = 0)
# A tibble: 3 × 4
  region Tablet Móvil Portátil
  <chr>   <dbl> <dbl>    <dbl>
1 Este        4    20        8
2 Oeste       6    25        4
3 Sur         7     0        8

Si los meses son fechas reales, es mejor extraer el mes de la columna de fecha. Aprende cómo trabajar con fechas en R.

Exportar la tabla dinámica a Excel

Una tabla creada con dplyr ya es un data frame, así que puedes guardarla directamente como archivo XLSX, por ejemplo con el paquete writexl:

# install.packages("writexl")
library(writexl)

write_xlsx(bind_rows(dinamica_tot, totales), "tabla_ingresos.xlsx")

Un objeto de tipo tabla creado con table, xtabs o tapply hay que convertirlo primero en data frame. Ojo: as.data.frame(tab) lo devuelve al formato largo. Para mantener la disposición cruzada usa as.data.frame.matrix y pasa los nombres de las filas a una columna para no perderlos:

tab_df <- as.data.frame.matrix(addmargins(tab))
tab_df <- cbind(region = rownames(tab_df), tab_df)
rownames(tab_df) <- NULL

tab_df
  region Móvil Portátil Tablet   Sum
1   Este 10000     8000   1200 19200
2  Oeste 12500     4000   1800 18300
3    Sur     0     8000   2100 10100
4    Sum 22500    20000   5100 47600

Ahora write_xlsx(tab_df, "tabla_ingresos.xlsx") escribe la tabla tal y como la ves. En el artículo sobre exportar datos en R tienes otros paquetes y formatos, como CSV.

¿Qué método deberías usar?

Método Recuentos Sumas Cualquier función Resultado Ideal para
table() Sí No No Tabla Recuentos rápidos de una o dos variables
xtabs() Sí Sí No Tabla Recuentos y sumas con fórmula, addmargins y prop.table
tapply() No Sí Sí Matriz Tabla cruzada de medias, medianas, etc. en R base
aggregate() Sí Sí Sí Data frame largo Varios campos de valores sin paquetes
group_by() + summarise() + pivot_wider() Sí Sí Sí Data frame ancho Tablas complejas, varios valores, exportar

Como regla general, usa xtabs cuando necesites recuentos o sumas con totales y porcentajes, y dplyr con pivot_wider para todo lo demás, sobre todo si vas a reutilizar o exportar el resultado.

Tablas dinámicas interactivas con rpivotTable

Si echas de menos arrastrar y soltar campos, el paquete rpivotTable crea una tabla dinámica interactiva en el visor de RStudio o en un informe HTML con rpivotTable(ventas, rows = "region", cols = "producto", vals = "ingresos", aggregatorName = "Sum"). Después puedes reorganizar los campos con el ratón, cambiar la función de resumen e incluso pasar a un mapa de calor o a un gráfico. Es útil para explorar datos, pero el resultado no es un objeto de R con el que puedas seguir trabajando, así que para análisis reproducibles quédate con los métodos anteriores.

Errores comunes

Warning: values are not uniquely identified; output will contain list-cols

Este aviso aparece cuando aplicas pivot_wider a los datos originales en lugar de a los datos resumidos. Varias filas comparten la misma región y producto, así que pivot_wider no sabe qué valor poner en la celda y los guarda todos en una lista:

ventas %>%
  select(region, producto, ingresos) %>%
  pivot_wider(names_from = producto, values_from = ingresos)
# A tibble: 3 × 4
  region Portátil  Móvil     Tablet   
  <chr>  <list>    <list>    <list>   
1 Este   <dbl [2]> <dbl [2]> <dbl [1]>
2 Oeste  <dbl [1]> <dbl [2]> <dbl [1]>
3 Sur    <dbl [2]> <NULL>    <dbl [1]>
Warning message:
Values from `ingresos` are not uniquely identified; output will contain
list-cols.
• Use `values_fn = list` to suppress this warning.
• Use `values_fn = {summary_fun}` to summarise duplicates.
• Use the following dplyr code to identify duplicates.
  {data} %>%
  dplyr::group_by(region, producto) %>%
  dplyr::summarise(n = dplyr::n(), .groups = "drop") %>%
  dplyr::filter(n > 1L)

Añade el paso de group_by y summarise antes de pivot_wider, o indica a pivot_wider cómo combinar los duplicados con values_fn:

ventas %>%
  pivot_wider(id_cols = region, names_from = producto, values_from = ingresos,
              values_fn = sum, values_fill = 0)
# A tibble: 3 × 4
  region Portátil Móvil Tablet
  <chr>     <dbl> <dbl>  <dbl>
1 Este       8000 10000   1200
2 Oeste      4000 12500   1800
3 Sur        8000     0   2100

id_cols = region es importante aquí: sin él, mes y unidades también se usarían para identificar las filas y obtendrías una fila por venta.

La tabla cuenta filas cuando querías sumar

table(ventas$region, ventas$producto) cuenta ventas (filas), no unidades. Si las celdas deben mostrar la suma de una columna, pon esa columna en el lado izquierdo de xtabs: xtabs(unidades ~ region + producto, data = ventas). Es la misma diferencia que hay entre “Cuenta de unidades” y “Suma de unidades” en Excel.