The classic results figure — bars with error bars — is really two steps: summarise the raw data, then plot the summary. Doing the summary explicitly (not inside the plot call) means the numbers in your figure are numbers you can also print, check, and quote.

Step 1 — Raw observations

library(ggplot2)

set.seed(3)
df <- data.frame(
  condition = rep(c("control", "low", "high"), each = 20),
  response  = c(rnorm(20, 5.0, 0.8),
                rnorm(20, 6.9, 1.0),
                rnorm(20, 9.4, 1.3))
)

Step 2 — Summarise in base R

aggregate computes per-group statistics; the standard error comes from sd and n:

m  <- aggregate(response ~ condition, df, mean)
s  <- aggregate(response ~ condition, df, sd)
n  <- aggregate(response ~ condition, df, length)

stats <- data.frame(
  condition = m$condition,
  mean = m$response,
  se   = s$response / sqrt(n$response)
)
print(stats)

Print it. This little table is your figure — and the numbers for the text.

Step 3 — Bars with error bars

ggplot(stats, aes(x = condition, y = mean)) +
  geom_col(width = 0.6, fill = "grey75") +
  geom_errorbar(aes(ymin = mean - se, ymax = mean + se), width = 0.15) +
  labs(x = NULL, y = "Response (mean ± SE)")

Say what the bars are in the axis label — mean ± SE and mean ± SD look identical and mean very different things.

Step 4 — Put the raw data back on

Twenty points per group fit on the figure; showing them is stronger than any error bar:

ggplot(stats, aes(x = condition, y = mean)) +
  geom_col(width = 0.6, fill = "grey85") +
  geom_errorbar(aes(ymin = mean - se, ymax = mean + se), width = 0.15) +
  geom_jitter(data = df, aes(y = response), width = 0.08,
              size = 1.5, alpha = 0.5) +
  labs(x = NULL, y = "Response") +
  theme_minimal(base_size = 13)

Note the layer trick: the jitter layer brings its own data = df (the raw observations) while the bars use the summary — two datasets, one figure, each layer naming its source.

Step 5 — Export or insert

Export / Insert — vector PDF or placed in the paper with a \label.

Tip: Fix the ordering of conditions with factor(condition, levels = c("control", "low", "high")) before plotting — otherwise ggplot sorts alphabetically and “control, high, low” tells the story in the wrong order.